Compare commits

...
7 Commits
Author SHA1 Message Date
fkwp eef86fc06e Merge pull request #4292 from element-hq/spec/background-effects/12-unbuildable-pipeline
Offer no effects for the session where the pipeline cannot be built
2026-11-06 18:27:12 +01:00
Timo K. cd5c367ce9 fix race in processor setup vs camera start 2026-11-06 16:54:42 +01:00
Timo K.andClaude Fable 5.1 190902eec0 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 16:54:42 +01:00
Timo K.andClaude Opus 5.5 88f748ec21 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 16:54:42 +01:00
Timo 9639effd28 Merge pull request #4423 from element-hq/toger5/background-effect/camera-preview
Camera Preview in video mute button context menu
2026-11-06 15:01:06 +00:00
Timo K. f2f67019b4 Show the self preview in the video mute button context menu 2026-11-06 15:35:04 +01:00
Timo K. 51fac690f4 Hand the camera track to the footer view model 2026-11-06 15:35:04 +01:00
25 changed files with 961 additions and 116 deletions
+100
View File
@@ -106,6 +106,66 @@ async function drawsPerSecond(page: Page): Promise<number> {
});
}
test.describe("the camera menu's preview", () => {
test("shows the camera with the effect in force", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Preview user", "Preview in the menu");
const camera = await cameraColour(page.locator("video").first());
await page.getByRole("button", { name: "Camera", exact: true }).click();
const preview = page.getByRole("menu").locator("video");
await expect(preview).toBeVisible();
await expect
.poll(async () => distance(await averageColour(preview), camera), {
timeout: 20_000,
})
.toBeLessThan(30);
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
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);
});
test("shows the call's own camera once joined", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(
page,
"Call preview user",
"Preview in the call's menu",
true,
);
await expect(page.getByTestId("incall_videomute")).toBeEnabled({
timeout: 10_000,
});
const camera = await cameraColour(page.locator("video").first());
await page.getByRole("button", { name: "Camera", exact: true }).click();
const preview = page.getByRole("menu").locator("video");
await expect(preview).toBeVisible();
await expect
.poll(async () => distance(await averageColour(preview), camera), {
timeout: 20_000,
})
.toBeLessThan(30);
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
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);
});
});
test.describe("background effects section", () => {
test("section stays within the call area at every size", async ({ page }) => {
test.setTimeout(120_000);
@@ -151,6 +211,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("chosen effect survives leaving and rejoining", async ({ page }) => {
test.setTimeout(120_000);
+1
View File
@@ -141,6 +141,7 @@ export const Default: Story = {
backgroundImageRefusal: undefined,
addBackgroundImage: fn(),
removeBackgroundEffect: fn(),
cameraTrack: null,
hangup: fn(),
buttonSize: "lg",
showFooter: true,
+6
View File
@@ -33,7 +33,9 @@ import { useBehavior } from "../useBehavior";
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
import { LayoutSwitch } from "../room/LayoutSwitch";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
import { SelfPreview } from "./SelfPreview";
import { type UnusableReason } from "../tracks/backgroundImages";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
export interface BackgroundImageRefusal {
reason: UnusableReason | "not-kept";
@@ -92,6 +94,8 @@ export interface FooterState {
backgroundEffectSettling: boolean;
/** Why the last file offered couldn't be kept, if it couldn't. */
backgroundImageRefusal: BackgroundImageRefusal | undefined;
/** The camera to preview, with the effect in force; null while it is off. */
cameraTrack: LocalVideoMediaTrack | null;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
@@ -181,6 +185,7 @@ export const CallFooter: FC<FooterProps> = ({
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
const cameraTrack = useBehavior(vm.cameraTrack$);
const refusal = useBehavior(vm.backgroundImageRefusal$);
const refusalMessage = useMemo(() => {
switch (refusal?.reason) {
@@ -264,6 +269,7 @@ export const CallFooter: FC<FooterProps> = ({
backgroundEffectSettling={backgroundEffectSettling}
onAddBackgroundImage={addBackgroundImage}
onRemoveBackgroundEffect={removeBackgroundEffect}
selfPreview={<SelfPreview track={cameraTrack} />}
backgroundImageRefusal={refusalMessage}
backgroundEffectNotice={
backgroundEffectNotice === "unavailable"
+94 -10
View File
@@ -7,14 +7,17 @@ Please see LICENSE in the repository root for full details.
import { afterEach, describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import {
flushPromises,
testScope,
mockMuteStates,
mockMediaDevices,
mockVideoTrack,
} from "../utils/test";
import { constant } from "../state/Behavior";
import { type Behavior, constant } from "../state/Behavior";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
@@ -78,6 +81,7 @@ vi.mock("livekit-client", () => ({
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
return {
layout$: constant(layout),
camera$: constant(undefined),
edgeToEdge$: constant(false),
showHeader$: constant(false),
hangup: (): void => {},
@@ -109,6 +113,16 @@ const pipLayout: Layout = {
spotlight: {} as SpotlightTileViewModel,
};
const noPipeline = constant<BackgroundEffectsState>({
supported: false,
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({
audioInput: {
available$: constant(
@@ -140,7 +154,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
constant(false),
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -183,7 +197,7 @@ describe("createCallFooterViewModel", () => {
select: vi.fn(),
},
}),
constant(false),
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -202,7 +216,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(layout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
constant(false),
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -225,7 +239,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
constant(false),
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -271,7 +285,8 @@ describe("createCallFooterViewModel", () => {
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
constant(false),
pipelineAsTheSdkSays(),
/* camera$ */ constant(null),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
@@ -356,21 +371,52 @@ describe("createCallFooterViewModel", () => {
it("passes on the wait for the first effect", () => {
platformMock.mockReturnValue("desktop");
const settling$ = new BehaviorSubject(true);
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "settling",
});
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
settling$,
pipeline$,
/* camera$ */ constant(null),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.backgroundEffectSettling$.value).toBe(true);
settling$.next(false);
pipeline$.next({ ...pipeline$.value, wait: undefined });
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$,
/* camera$ */ constant(null),
/* 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", () => {
sdkSupportMock.mockReturnValue(true);
const added$ = addedBackgrounds.added$ as BehaviorSubject<
@@ -427,6 +473,44 @@ describe("createCallFooterViewModel", () => {
expect(store.remove).toHaveBeenCalledWith("spare");
});
it("previews the lobby's camera while there is one", () => {
const track = mockVideoTrack({});
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
camera$,
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.cameraTrack$.value).toBe(track);
// The lobby releases the camera when it is turned off
camera$.next(null);
expect(vm.cameraTrack$.value).toBeNull();
});
it("previews the call's camera only while it is not muted", () => {
const muted$ = new BehaviorSubject(false);
const track = mockVideoTrack({ muted$ });
const vm = createCallFooterViewModel(
testScope(),
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.cameraTrack$.value).toBe(track);
muted$.next(true);
expect(vm.cameraTrack$.value).toBeNull();
muted$.next(false);
expect(vm.cameraTrack$.value).toBe(track);
});
it("availability is the same before and during a call", () => {
for (const supported of [true, false]) {
sdkSupportMock.mockReturnValue(supported);
@@ -436,7 +520,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
constant(false),
pipelineAsTheSdkSays(),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
+46 -19
View File
@@ -11,13 +11,12 @@ import {
distinctUntilChanged,
map,
type Observable,
of,
switchMap,
} from "rxjs";
import { supportsAudioOutputSelection } from "livekit-client";
import {
supportsBackgroundProcessors,
supportsModernBackgroundProcessors,
} from "@livekit/track-processors";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import {
@@ -33,6 +32,7 @@ import {
} from "../tracks/backgroundImages";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import {
@@ -87,7 +87,9 @@ function buildDeviceBehaviors(
mediaDevices: MediaDevices,
/** return empty arrays for audioOptions and videoOptions*/
disableSwitcher$: Behavior<boolean>,
backgroundEffectSettling$: Observable<boolean>,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
/** The camera as the pipeline has it, for the menu's preview. */
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions$"
@@ -107,6 +109,7 @@ function buildDeviceBehaviors(
| "addBackgroundImage$"
| "backgroundImageRefusal$"
| "removeBackgroundEffect$"
| "cameraTrack$"
> {
const options$ = (
available$: Behavior<Map<string, MenuOptions["label"]>>,
@@ -123,10 +126,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 offered$ = disableSwitcher$.pipe(
map((switcherDisabled) => !switcherDisabled && supported),
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
);
// A new object for each refusal, so the same one twice is shown twice.
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
@@ -157,8 +164,10 @@ function buildDeviceBehaviors(
),
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
backgroundEffect$: scope.behavior(
backgroundEffectSetting.value$.pipe(
map((raw) => (supported ? serializeEffect(parseEffect(raw)) : "none")),
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
map(([raw, supported]) =>
supported ? serializeEffect(parseEffect(raw)) : "none",
),
),
),
selectBackgroundEffect$: scope.behavior(
@@ -186,7 +195,10 @@ function buildDeviceBehaviors(
),
),
backgroundEffectSettling$: scope.behavior(
backgroundEffectSettling$.pipe(distinctUntilChanged()),
backgroundEffectsState$.pipe(
map(({ wait }) => wait !== undefined),
distinctUntilChanged(),
),
false,
),
// Kept and offered, not put on: that waits for the user to choose it.
@@ -208,6 +220,17 @@ function buildDeviceBehaviors(
),
),
backgroundImageRefusal$: refusal$,
// None while muted: the call mutes its track rather than releasing it.
cameraTrack$: scope.behavior(
camera$.pipe(
switchMap((camera) =>
camera
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
: of(null),
),
),
null,
),
// Never the one in force: taking it away would leave nothing chosen.
removeBackgroundEffect$: scope.behavior(
offered$.pipe(
@@ -237,8 +260,8 @@ function buildDeviceBehaviors(
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param backgroundEffectSettling$ - Whether the first effect chosen is still
* being prepared.
* @param backgroundEffectsState$ - The background pipeline's state: whether
* effects run here, and the wait for the first.
* @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button).
* @param options - `showControls`: whether the call controls should be shown.
@@ -249,7 +272,7 @@ export function createCallFooterViewModel(
callModel: CallViewModel,
muteStates: MuteStates,
mediaDevices: MediaDevices,
backgroundEffectSettling$: Observable<boolean>,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
reactionIdentifier: string | undefined,
options: { showControls: boolean; header: HeaderStyle },
): ViewModel<FooterSnapshot> {
@@ -268,7 +291,8 @@ export function createCallFooterViewModel(
scope,
mediaDevices,
disableDeviceSwitcher$,
backgroundEffectSettling$,
backgroundEffectsState$,
callModel.camera$,
),
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
@@ -331,8 +355,9 @@ export function createCallFooterViewModel(
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param backgroundEffectSettling$ - Whether the first effect chosen is still
* being prepared.
* @param backgroundEffectsState$ - The background pipeline's state: whether
* effects run here, and the wait for the first.
* @param camera$ - The lobby's camera, for the camera menu's preview.
* @param openSettings - Callback to open the settings modal, or undefined.
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
* @param showLogo - Whether to show the Element Call logo.
@@ -341,7 +366,8 @@ export function createLobbyFooterViewModel(
scope: ObservableScope,
muteStates: MuteStates,
mediaDevices: MediaDevices,
backgroundEffectSettling$: Observable<boolean>,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
camera$: Behavior<LocalVideoMediaTrack | null>,
openSettings: (() => void) | undefined,
hangup: (() => void) | undefined,
showLogo: boolean,
@@ -389,7 +415,8 @@ export function createLobbyFooterViewModel(
scope,
mediaDevices,
constant(false),
backgroundEffectSettling$,
backgroundEffectsState$,
camera$,
),
};
}
@@ -97,3 +97,35 @@ Please see LICENSE in the repository root for full details.
.refusal {
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
}
.selfPreview {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
aspect-ratio: 16 / 9;
margin-block-end: var(--cpd-space-2x);
overflow: clip;
border-start-start-radius: var(--cpd-space-3x);
border-start-end-radius: var(--cpd-space-3x);
background: var(--cpd-color-bg-subtle-secondary);
color: var(--cpd-color-icon-secondary);
}
.selfPreview > video {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
/* Rounded itself too: a video can be drawn on a layer of its own, which the
frame's rounded clip doesn't always reach. */
border-radius: inherit;
}
/* Up to the menu's frame, over the padding at its top. */
.selfPreviewPinned,
.deviceListLeadsWithPreview {
margin-block-start: calc(-1 * var(--cpd-space-5x));
}
@@ -12,6 +12,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import styles from "./MediaMuteAndSwitchButton.module.css";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
import { SelfPreview } from "./SelfPreview";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
import meterStyles from "./MicrophoneLevelMeter.module.css";
import { MediaDevicesContext } from "../MediaDevicesContext";
@@ -60,6 +61,50 @@ const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
return <>{children}</>;
};
/** A camera for the preview: a figure against a room, drawn to a stream. */
const FakeCamera: FC = () => {
const [stream, setStream] = useState<MediaStream>();
useEffect(() => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const context = canvas.getContext("2d")!;
const draw = (): void => {
context.fillStyle = "#6b7f8e";
context.fillRect(0, 0, 640, 360);
context.fillStyle = "#c9a27e";
context.beginPath();
context.arc(320, 150, 62, 0, 2 * Math.PI);
context.fill();
context.fillStyle = "#34495e";
context.beginPath();
context.ellipse(320, 380, 150, 140, 0, Math.PI, 2 * Math.PI);
context.fill();
};
draw();
// Redrawn, so the stream keeps delivering frames.
const timer = setInterval(draw, 200);
const captured = canvas.captureStream(10);
setStream(captured);
return (): void => {
clearInterval(timer);
captured.getTracks().forEach((track) => track.stop());
};
}, []);
return (
<video
data-testid="preview-camera"
autoPlay
muted
playsInline
ref={(video): void => {
if (video && stream && video.srcObject !== stream)
video.srcObject = stream;
}}
/>
);
};
/** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
const WithACallArea: FC<{ children: ReactNode; blockSize?: number }> = ({
children,
@@ -171,8 +216,8 @@ export const VideoMute: Story = {
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedOption: "1",
selfPreview: <SelfPreview track={null} />,
},
};
@@ -199,6 +244,8 @@ export const VideoUnmute: Story = {
selectedBackgroundEffect: "none",
onSelectBackgroundEffect: fn(),
onAddBackgroundImage: fn(),
// As the footer always gives the camera menu one.
selfPreview: <FakeCamera />,
},
};
@@ -344,6 +391,9 @@ export const BackgroundEffectsSettling: Story = {
checked: false,
}))
await expect(other).toHaveAttribute("aria-busy", "false");
await expect(menu.queryByTestId("preview-camera")).toBeNull();
const frames = document.body.querySelectorAll(`.${styles.selfPreview} svg`);
await expect(frames).toHaveLength(1);
},
};
@@ -501,6 +551,113 @@ export const RemovingWithLiveSelection: Story = {
},
};
/** Where the call leaves room, the camera holds the top while the options scroll. */
export const BackgroundEffectsWithPreview: Story = {
args: {
...VideoUnmute.args,
// Enough that the list fills its share, so the preview's cost counts.
options: Array.from({ length: 8 }, (_, i) => ({
label: { type: "name" as const, name: `Camera ${i + 1}` },
id: `${i + 1}`,
})),
},
decorators: [
(Story): JSX.Element => (
<WithACallArea blockSize={640}>
<Story />
</WithACallArea>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const picture = await within(document.body).findByTestId("preview-camera");
const preview = picture.parentElement!;
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
const box = preview.getBoundingClientRect();
await expect(list.contains(preview)).toBe(false);
await expect(box.top - frame.top).toBeLessThanOrEqual(2);
await expect(frame.width - box.width).toBeLessThanOrEqual(2);
await expect(box.width / box.height).toBeCloseTo(16 / 9, 1);
await expect(box.bottom).toBeLessThanOrEqual(
list.getBoundingClientRect().top,
);
const listShare = Math.round(640 * 0.6);
// The list runs on through the menu's padding at its foot, which isn't
// part of its share.
const listEnd =
list.getBoundingClientRect().bottom -
Number.parseFloat(getComputedStyle(list).paddingBlockEnd);
await expect(listEnd - box.top).toBeLessThanOrEqual(listShare + 1);
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
list.scrollTop = list.scrollHeight;
await expect(preview.getBoundingClientRect().top).toBe(box.top);
},
};
/** With the camera off the preview says so, and effects can still be chosen. */
export const BackgroundEffectsPreviewWithTheCameraOff: Story = {
args: {
...VideoUnmute.args,
enabled: false,
selfPreview: <SelfPreview track={null} />,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const menu = within(document.body);
await menu.findByRole("group", { name: "Background effects" });
const preview = document.body.querySelector(`.${styles.selfPreview}`)!;
await expect(preview.querySelector("video")).toBeNull();
await expect(preview.querySelector("svg")).not.toBeNull();
await userEvent.click(menu.getByRole("menuitemradio", { name: "Blur" }));
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
},
};
/** In a call too short to hold it, the camera leads the options and scrolls away. */
export const BackgroundEffectsPreviewScrollsWithTheList: Story = {
args: VideoUnmute.args,
decorators: [
(Story): JSX.Element => (
<WithACallArea blockSize={300}>
<Story />
</WithACallArea>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const picture = await within(document.body).findByTestId("preview-camera");
const preview = picture.parentElement!;
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
await expect(list.contains(preview)).toBe(true);
const cameras = within(document.body).getByRole("group", {
name: "Camera",
});
await expect(
preview.compareDocumentPosition(cameras) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
list.scrollTop = list.scrollHeight;
await expect(preview.getBoundingClientRect().bottom).toBeLessThan(
list.getBoundingClientRect().top,
);
},
};
/** In a short call the effects scroll into view with the list. */
export const BackgroundEffectsScrollWhenTheyDoNotFit: Story = {
args: {
+54 -6
View File
@@ -14,10 +14,12 @@ import {
type FC,
useEffect,
type ReactElement,
type ReactNode,
} from "react";
import {
Alert,
Button,
InlineSpinner,
Menu,
MenuItem,
MenuScrollArea,
@@ -87,6 +89,8 @@ export interface MediaMuteAndSwitchButtonProps {
onRemoveBackgroundEffect?: (id: string) => void;
/** Why the last file offered couldn't be used; a new object each time. */
backgroundImageRefusal?: { text: string };
/** The user's own camera, shown at the top of the camera menu. */
selfPreview?: ReactNode;
/**
* For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
@@ -103,9 +107,16 @@ const LIST_SHARE_OF_CALL = 0.6;
/** Smallest device list height in px, so a short call still shows more than one device. */
const MIN_LIST_HEIGHT = 160;
/** The menu's width as its stylesheet sets it, to place the preview before
the menu is drawn. */
const MENU_WIDTH = 296;
/** Space kept between the menu and the call area's sides. */
const MENU_MARGIN = 16;
/** Below the preview, as its stylesheet draws it. */
const PREVIEW_GAP = 12;
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
enabled,
busy,
@@ -124,6 +135,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
onAddBackgroundImage,
onRemoveBackgroundEffect,
backgroundImageRefusal,
selfPreview,
onSelect,
}) => {
// Requested but not yet selected. Keyed by kind too, since Chrome uses
@@ -202,21 +214,53 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
// Measured on the call area: CSS can't size the portalled menu against it.
const rootElement = useRootElement();
const [listMaxHeight, setListMaxHeight] = useState<number>();
const [listShare, setListShare] = useState<number>();
const [previewBlock, setPreviewBlock] = useState(0);
useEffect(() => {
if (!menuOpen) return;
// Followed, since a host can resize the call while the menu is open.
const subscription = observeElementSize$(rootElement)
.pipe(
map(({ height }) =>
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
map(({ width, height }) => ({
height: Math.round(height * LIST_SHARE_OF_CALL),
width: Math.min(MENU_WIDTH, Math.round(width - 2 * MENU_MARGIN)),
})),
distinctUntilChanged(
(a, b) => a.height === b.height && a.width === b.width,
),
distinctUntilChanged(),
)
.subscribe(setListMaxHeight);
.subscribe(({ height, width }) => {
setListShare(height);
setPreviewBlock(Math.round((width * 9) / 16) + PREVIEW_GAP);
});
return (): void => subscription.unsubscribe();
}, [menuOpen, rootElement]);
// Paid for out of the list's share, so a preview makes the menu no taller.
const hasPreview =
iconsAndLabels === "video" &&
selfPreview !== undefined &&
listShare !== undefined;
const previewPinned =
hasPreview && listShare - previewBlock >= MIN_LIST_HEIGHT;
const listMaxHeight =
listShare === undefined
? undefined
: Math.max(
MIN_LIST_HEIGHT,
listShare - (previewPinned ? previewBlock : 0),
);
const preview = hasPreview && (
<div
aria-hidden
className={classNames(styles.selfPreview, {
[styles.selfPreviewPinned]: previewPinned,
})}
>
{backgroundEffectSettling ? <InlineSpinner size={32} /> : selfPreview}
</div>
);
useEffect(() => {
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]);
@@ -407,9 +451,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/>
}
>
{previewPinned && preview}
<MenuScrollArea
ref={trackFocusSource}
className={styles.deviceList}
className={classNames(styles.deviceList, {
[styles.deviceListLeadsWithPreview]: hasPreview && !previewPinned,
})}
data-testid="device-list"
style={
{
@@ -418,6 +465,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
} as CSSProperties
}
>
{hasPreview && !previewPinned && preview}
{audioMeter && speakerOptions && (
<MenuScrollSection title={t("settings.devices.speaker")}>
{deviceItems(
+10
View File
@@ -0,0 +1,10 @@
/*
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.
*/
.mirrored {
transform: scaleX(-1);
}
+47
View File
@@ -0,0 +1,47 @@
/*
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 { act, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BehaviorSubject } from "rxjs";
import { SelfPreview } from "./SelfPreview";
import styles from "./SelfPreview.module.css";
import { mockVideoTrack } from "../utils/test";
describe("SelfPreview", () => {
it("detaches only its own element from a camera shown twice", () => {
const track = mockVideoTrack({});
const first = render(<SelfPreview track={track} />);
const second = render(<SelfPreview track={track} />);
const [a, b] = [first, second].map((r) =>
r.container.querySelector("video"),
);
expect(track.attach).toHaveBeenCalledWith(a);
expect(track.attach).toHaveBeenCalledWith(b);
first.unmount();
expect(track.detach).toHaveBeenCalledExactlyOnceWith(a);
});
it("mirrors the camera while it faces the user, and only then", () => {
const facingMode$ = new BehaviorSubject<"user" | "environment">("user");
const { container } = render(
<SelfPreview track={mockVideoTrack({ facingMode$ })} />,
);
const video = (): HTMLVideoElement => container.querySelector("video")!;
expect(video().classList.contains(styles.mirrored)).toBe(true);
act(() => facingMode$.next("environment"));
expect(video().classList.contains(styles.mirrored)).toBe(false);
});
it("says the camera is off while there is none", () => {
const { container } = render(<SelfPreview track={null} />);
expect(container.querySelector("video")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
/*
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 { type FC, useEffect, useRef } from "react";
import { constant, type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { VideoCallOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./SelfPreview.module.css";
import { useBehavior } from "../useBehavior";
const noFacingMode = constant(undefined);
interface Props {
/** The camera as the pipeline has it, or none while the camera is off. */
track: LocalVideoMediaTrack | null;
}
/**
* The user's own camera with the effect in force: the pipeline's own track,
* shown a second time rather than captured again.
*/
export const SelfPreview: FC<Props> = ({ track }) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const element = videoRef.current;
if (!track || !element) return;
track.attach(element);
// This element only: the pre-join preview may be showing the same camera
return (): void => {
track.detach(element);
};
}, [track]);
const facingMode = useBehavior(track?.facingMode$ ?? noFacingMode);
if (!track) return <VideoCallOffIcon width={32} height={32} />;
return (
<video
ref={videoRef}
muted
playsInline
className={facingMode === "user" ? styles.mirrored : undefined}
/>
);
};
+3 -6
View File
@@ -75,7 +75,6 @@ import {
type RTCSlotOptions,
constant,
} from "@element-hq/matrixrtc-sdk";
import { map } from "rxjs";
import {
advancedCamera,
advancedScreenShare,
@@ -181,7 +180,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.muteStates,
mediaDevices,
capture,
backgroundEffectsState$.value.processor,
backgroundEffectsState$.value,
),
});
@@ -195,9 +194,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
capture,
videoProcessor$: scope.behavior(
backgroundEffectsState$.pipe(map((state) => state.processor)),
),
backgroundEffectsState$,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
@@ -241,7 +238,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
vm,
props.muteStates,
mediaDevices,
backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)),
backgroundEffectsState$,
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
{ showControls: urlParams.showControls, header: urlParams.header },
);
+5 -5
View File
@@ -16,7 +16,6 @@ import {
type LocalMedia,
type LocalVideoMediaTrack,
} from "@element-hq/matrixrtc-sdk";
import { map } from "rxjs";
import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css";
@@ -134,9 +133,7 @@ export const LobbyView: FC<Props> = ({
muteStates,
devices,
captureSettings(),
mediaScope.behavior(
backgroundEffectsState$.pipe(map((state) => state.processor)),
),
backgroundEffectsState$,
),
);
return (): void => {
@@ -158,13 +155,15 @@ export const LobbyView: FC<Props> = ({
null,
);
useEffect(() => {
if (mediaVm === null) return;
const footerScope = new ObservableScope();
setFooterVm(
createLobbyFooterViewModel(
footerScope,
muteStates,
devices,
backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)),
backgroundEffectsState$,
mediaVm.videoTrack$,
openSettings,
hangup,
// Logo and header are connected: only show the logo in SPA with header.
@@ -179,6 +178,7 @@ export const LobbyView: FC<Props> = ({
backgroundEffectsState$,
hangup,
hideHeader,
mediaVm,
muteStates,
openSettings,
]);
+41 -19
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 { BehaviorSubject, distinctUntilChanged, map } from "rxjs";
import { BehaviorSubject, distinctUntilChanged, map, of } from "rxjs";
import { type BackgroundProcessorWrapper } from "@livekit/track-processors";
import {
@@ -47,23 +47,28 @@ 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 {
if (state.supported === false) return "x";
if (state.wait === "preparing") return "p";
if (state.processor === undefined) return "i";
return state.settling ? "w" : "a";
return state.wait === "settling" ? "w" : "a";
}
describe("the pipeline's state", () => {
function testState({
effect,
answer = "-y",
firstFrame = "",
expected,
}: {
effect: string;
/** When the trial build answers, from being asked. */
answer?: string;
firstFrame?: string;
expected: string;
}): void {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
const transformer = {
onFirstFrame: undefined as (() => void) | undefined,
};
@@ -72,6 +77,7 @@ describe("the pipeline's state", () => {
effect$: behavior(effect, { n: "none", b: "blur" }),
setEffect: vi.fn(),
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
canSegment: () => cold(answer, { y: true, n: false }),
pipeline: fakePipeline().pipeline,
transformer,
});
@@ -84,15 +90,21 @@ describe("the pipeline's state", () => {
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", () =>
testState({
effect: " nb-n-b",
firstFrame: " --f",
expected: " iwa",
effect: " nb---n-b",
firstFrame: "---f",
expected: " ipwa",
}));
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", () => {
@@ -109,6 +121,7 @@ describe("background effects", () => {
effect$,
setEffect,
added$,
canSegment: () => of(true),
pipeline: fake.pipeline,
transformer: { onFirstFrame: undefined },
...options,
@@ -118,8 +131,6 @@ describe("background effects", () => {
effect$.next(raw);
await flushPromises();
};
const blur = async (on: boolean): Promise<void> =>
choose(on ? "blur" : "none");
beforeEach(() => {
effect$ = new BehaviorSubject("none");
@@ -128,6 +139,17 @@ describe("background effects", () => {
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.wait).toBeUndefined();
// 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 () => {
build();
await choose(`image:${shippedBackgrounds[0].id}`);
@@ -141,10 +163,10 @@ describe("background effects", () => {
it("switches in place rather than reattaching", async () => {
const effects = build();
await blur(true);
await choose("blur");
const pipeline = effects.state$.value.processor;
await blur(false);
await blur(true);
await choose("none");
await choose("blur");
expect(effects.state$.value.processor).toBe(pipeline);
expect(fake.switches).toEqual([
@@ -157,18 +179,18 @@ describe("background effects", () => {
it("keeps switching after a switch fails", async () => {
vi.mocked(fake.pipeline.switchTo).mockRejectedValueOnce(new Error("lost"));
build();
await blur(true);
await blur(false);
await choose("blur");
await choose("none");
expect(fake.switches).toEqual([{ mode: "disabled" }]);
});
it("switches one at a time, skipping those overtaken", async () => {
const finished = fake.holdNext();
build();
await blur(true);
await blur(false);
await blur(true);
await blur(false);
await choose("blur");
await choose("none");
await choose("blur");
await choose("none");
expect(fake.switches).toHaveLength(1);
finished();
+45 -11
View File
@@ -8,9 +8,15 @@ Please see LICENSE in the repository root for full details.
import {
combineLatest,
distinctUntilChanged,
first,
from,
map,
Observable,
type ObservableInput,
of,
scan,
startWith,
switchMap,
} from "rxjs";
import {
type BackgroundProcessorWrapper,
@@ -38,8 +44,12 @@ const blurRadius = 15;
export interface BackgroundEffectsState {
supported: boolean | undefined;
processor: undefined | VideoProcessor;
/** From the first effect chosen until a frame carrying it is drawn. */
settling?: boolean;
/**
* The effect is not ready:
* preparing, asking whether the pipeline can be built.
* settling, once it is attached until a frame carrying the effect
*/
wait?: "preparing" | "settling";
}
export interface BackgroundEffectsOptions {
@@ -51,6 +61,8 @@ export interface BackgroundEffectsOptions {
setEffect: (id: EffectId) => void;
/** The backgrounds the device keeps, undefined until it has said. */
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. */
pipeline: BackgroundProcessorWrapper;
/** Tells, once, that a frame carrying an effect has been drawn. */
@@ -70,6 +82,7 @@ export function createBackgroundEffects(
effect$,
setEffect,
added$,
canSegment,
pipeline,
transformer,
}: BackgroundEffectsOptions,
@@ -77,9 +90,11 @@ export function createBackgroundEffects(
// Wrapped once, so the call and the preview see one processor throughout.
const processor = fromLivekitProcessor(pipeline);
const choice$ = effect$.pipe(map(parseEffect));
const wanted$ = choice$.pipe(
map((effect) => effect.kind !== "none"),
distinctUntilChanged(),
const wanted$ = scope.behavior(
choice$.pipe(
map((effect) => effect.kind !== "none"),
distinctUntilChanged(),
),
);
combineLatest([choice$, added$])
@@ -95,6 +110,18 @@ export function createBackgroundEffects(
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(
new Observable<boolean>((subscriber) => {
subscriber.next(false);
@@ -105,18 +132,25 @@ export function createBackgroundEffects(
}),
);
// In one step, so that nothing sees the pipeline neither preparing nor
// attached.
const state$ = scope.behavior(
combineLatest([wanted$, drewAFrame$]).pipe(
scan<[boolean, boolean], BackgroundEffectsState>(
(previous, [wanted, drewAFrame]) => {
combineLatest([wanted$, buildable$, drewAFrame$]).pipe(
scan<[boolean, boolean | undefined, boolean], BackgroundEffectsState>(
(previous, [wanted, buildable, drewAFrame]) => {
// Attached the first time an effect is wanted and never detached
// after, so someone who never turns one on pays for none of it.
const enable =
previous.processor !== undefined || (supported && wanted);
previous.processor !== undefined || (wanted && buildable === true);
const preparing = supported && wanted && buildable === undefined;
return {
supported,
supported: supported && buildable !== false,
processor: enable ? processor : undefined,
settling: enable && !drewAFrame,
wait: preparing
? "preparing"
: enable && !drewAFrame
? "settling"
: undefined,
};
},
{ supported, processor: undefined },
+93 -6
View File
@@ -70,9 +70,11 @@ import {
} from "./CallViewModel.ts";
import { type Handler, MuteState } from "../MuteStates.ts";
import {
type LocalVideoMediaTrack,
type RTCParticipation,
type VideoProcessor,
} from "@element-hq/matrixrtc-sdk";
import { type BackgroundEffectsState } from "../BackgroundEffects";
initializeWidget();
@@ -1692,28 +1694,113 @@ describe("a camera with a video processor", () => {
muteStates,
mockMediaDevices({}),
{},
processor,
{ supported: true, processor },
).find((request) => request.source === "camera"),
).toMatchObject({ processor });
});
it("leaves the camera out of the join while the processor is prepared", () => {
const muteStates = mockMuteStates();
vi.spyOn(muteStates.video.enabled$, "value", "get").mockReturnValue(true);
const sources = (wait?: "preparing"): string[] =>
initialPublishRequests(
muteStates,
mockMediaDevices({}),
{},
{
supported: true,
processor: undefined,
wait,
},
).map((request) => request.source);
expect(sources("preparing")).not.toContain("camera");
expect(sources()).toContain("camera");
});
it("waits to make a camera track until it is known the processor builds", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "preparing",
});
let participation: RTCParticipation | undefined;
withCallViewModel(
{},
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ backgroundEffectsState$: effects$ },
);
const camera = participation!.localMember$.value?.tracks$.value?.find(
(track) => track.source === "camera",
) as LocalVideoMediaTrack;
const turnedOn = cameraHandler(setHandler)(true);
await flushPromises();
expect(camera.setEnabled).not.toHaveBeenCalledWith(true);
effects$.next({ supported: true, processor });
await turnedOn;
expect(camera.setEnabled).toHaveBeenCalledWith(true);
expect(participation!.publish).not.toHaveBeenCalled();
});
it("turns the camera on only once its processor is on the track", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "preparing",
});
let participation: RTCParticipation | undefined;
withCallViewModel(
{},
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ backgroundEffectsState$: effects$ },
);
const camera = participation!.localMember$.value?.tracks$.value?.find(
(track) => track.source === "camera",
) as LocalVideoMediaTrack;
// Attaching takes its time, as the pipeline is built on the track
let attached!: () => void;
vi.mocked(camera.setProcessor).mockReturnValueOnce(
new Promise<void>((resolve) => {
attached = resolve;
}),
);
const turnedOn = cameraHandler(setHandler)(true);
effects$.next({ supported: true, processor });
await flushPromises();
expect(camera.setProcessor).toHaveBeenCalledWith(processor);
expect(camera.setEnabled).not.toHaveBeenCalledWith(true);
attached();
await turnedOn;
expect(camera.setEnabled).toHaveBeenCalledWith(true);
});
// The SDK builds the processor on the camera's restart, before the room
// sees a frame, so the track is unmuted rather than published anew
it("turns on a camera turned off before the processor was chosen", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const videoProcessor$ = new BehaviorSubject<VideoProcessor | undefined>(
undefined,
);
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
});
let participation: RTCParticipation | undefined;
withCallViewModel(
{},
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ videoProcessor$ },
{ backgroundEffectsState$: effects$ },
);
videoProcessor$.next(processor);
effects$.next({ supported: true, processor });
await cameraHandler(setHandler)(true);
await flushPromises();
+65 -8
View File
@@ -37,6 +37,7 @@ import {
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
fromEvent,
map,
merge,
@@ -77,6 +78,7 @@ import {
type ReactionInfo,
type ReactionOption,
} from "../../reactions";
import { type BackgroundEffectsState } from "../BackgroundEffects";
import { shallowEquals as arrayShallowEquals } from "../../utils/array";
import { type MediaDevices } from "../MediaDevices";
import { type MuteStates } from "../MuteStates";
@@ -170,13 +172,16 @@ export function initialPublishRequests(
muteStates: MuteStates,
mediaDevices: MediaDevices,
capture: CaptureSettings,
processor?: VideoProcessor,
effects?: BackgroundEffectsState,
): PublishRequest[] {
const requests: PublishRequest[] = [];
if (muteStates.audio.enabled$.value)
requests.push(publishRequest("microphone", mediaDevices, capture));
if (muteStates.video.enabled$.value)
requests.push(publishRequest("camera", mediaDevices, capture, processor));
// Left out while the processor is prepared; the call publishes it after
if (muteStates.video.enabled$.value && effects?.wait !== "preparing")
requests.push(
publishRequest("camera", mediaDevices, capture, effects?.processor),
);
return requests;
}
@@ -187,8 +192,8 @@ export interface CallViewModelOptions {
* defaults otherwise.
*/
capture?: CaptureSettings;
/** Background blur and the like, applied to the camera as it changes. */
videoProcessor$?: Behavior<VideoProcessor | undefined>;
/** The background effects state (preparing, supported...) including the processor itself. */
backgroundEffectsState$?: Behavior<BackgroundEffectsState>;
/**
* The application hosting Element Call, which can ask it to hang up and wants
* to know when the user joins or leaves. Defaults to no host.
@@ -363,6 +368,8 @@ export interface CallViewModel {
keyRotationSuppressed$: Behavior<boolean>;
/** The local member, once our membership has been seen in the room. */
localMember$: Behavior<LocalRTCMember | null>;
/** Our own camera track, while one is published. */
camera$: Behavior<LocalVideoMediaTrack | undefined>;
remoteMembers$: Behavior<RemoteRTCMember[]>;
/** The transports the client holds a connection to, for the developer panel. */
connectedTransports$: Behavior<TransportMetadata[]>;
@@ -488,7 +495,21 @@ export function createCallViewModel$(
sentCallNotification$ = of(null),
} = options;
const { capture = {}, videoProcessor$ = constant(undefined) } = options;
const {
capture = {},
backgroundEffectsState$ = constant<BackgroundEffectsState>({
supported: false,
processor: undefined,
}),
} = options;
const videoProcessor$ = scope.behavior(
backgroundEffectsState$.pipe(map(({ processor }) => processor)),
);
// Resolves once it is known whether the processor builds
const processorKnown = async (): Promise<unknown> =>
firstValueFrom(
backgroundEffectsState$.pipe(filter(({ wait }) => wait !== "preparing")),
);
const localTracks$ = scope.behavior<
(LocalAudioMediaTrack | LocalVideoMediaTrack)[]
>(
@@ -512,6 +533,19 @@ export function createCallViewModel$(
"screenShare",
) as Behavior<LocalVideoMediaTrack | undefined>;
const processorAttached = followProcessor(
scope,
camera$,
videoProcessor$,
logger,
);
// A camera turned on waits for its processor to be known, then to be on
// the track: LiveKit restarts the track with whatever processor it has then.
const cameraReady = async (): Promise<void> => {
await processorKnown();
await processorAttached();
};
const supply = (source: "microphone" | "camera"): TrackSupply => ({
publish: async () =>
rtcParticipation.publish(
@@ -522,6 +556,7 @@ export function createCallViewModel$(
source === "camera" ? videoProcessor$.value : undefined,
),
),
ready: source === "camera" ? cameraReady : undefined,
});
followMuteState(
scope,
@@ -548,6 +583,29 @@ export function createCallViewModel$(
);
followDevice(scope, camera$, mediaDevices.videoInput, "camera", logger);
// A camera wanted at the join was left out of it while the processor was
// prepared (see initialPublishRequests), and is published once that is known
if (
backgroundEffectsState$.value.wait === "preparing" &&
muteStates.video.enabled$.value
)
processorKnown()
.then(async () => {
if (camera$.value !== undefined || !muteStates.video.enabled$.value)
return;
await rtcParticipation.publish(
publishRequest(
"camera",
mediaDevices,
capture,
videoProcessor$.value,
),
);
})
.catch((e) => {
logger.error("Failed to publish the camera", e);
});
// The output device follows the selection onto the client the same way. A
// host that routes audio itself picks the output outside the browser, so the
// client is left alone
@@ -561,8 +619,6 @@ export function createCallViewModel$(
});
});
followProcessor(scope, camera$, videoProcessor$, logger);
const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>(
combineLatest(
[rtcParticipation.localMember$, rtcParticipation.remoteMembers$],
@@ -1508,6 +1564,7 @@ export function createCallViewModel$(
leave,
toggleScreenSharing: toggleScreenSharing,
sharingScreen$: sharingScreen$,
camera$,
tapScreen: (): void => screenTap$.next(),
tapControls: (): void => controlsTap$.next(),
+2 -1
View File
@@ -21,6 +21,7 @@ import { type MediaDevices } from "./MediaDevices";
import { MuteStates } from "./MuteStates";
import { nullHostBridge } from "../HostBridge";
import { flushPromises, mockMediaDevices, testScope } from "../utils/test";
import { type BackgroundEffectsState } from "./BackgroundEffects";
describe("LobbyMediaViewModel", () => {
it("opens what starts enabled in one capture and follows the devices", async () => {
@@ -135,7 +136,7 @@ function setup(
muteStates,
mediaDevices,
{},
constant(processor),
constant<BackgroundEffectsState>({ supported: true, processor }),
);
return { media, vm, muteStates, mediaDevices };
}
+14 -4
View File
@@ -13,7 +13,6 @@ import {
type ObservableScope,
type PublishRequest,
trackBySource$,
type VideoProcessor,
} from "@element-hq/matrixrtc-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { combineLatest, debounceTime, map } from "rxjs";
@@ -25,6 +24,7 @@ import {
import { type MediaDevices } from "./MediaDevices";
import { type MuteStates } from "./MuteStates";
import { followDevice, followProcessor } from "./localMediaControls";
import { type BackgroundEffectsState } from "./BackgroundEffects";
export interface LobbyMediaViewModel {
/** The camera, once open, for the preview. */
@@ -42,9 +42,13 @@ export function createLobbyMediaViewModel(
muteStates: MuteStates,
mediaDevices: MediaDevices,
capture: CaptureSettings,
videoProcessor$: Behavior<VideoProcessor | undefined>,
/** No camera is opened while its pipeline is prepared: it would show the room without the processor. */
backgroundEffectsState$: Behavior<BackgroundEffectsState>,
): LobbyMediaViewModel {
const logger = rootLogger.getChild("[LobbyMedia]");
const videoProcessor$ = scope.behavior(
backgroundEffectsState$.pipe(map(({ processor }) => processor)),
);
const microphone$ = trackBySource$(
scope,
media.tracks$,
@@ -64,13 +68,19 @@ export function createLobbyMediaViewModel(
muteStates.video.enabled$,
microphone$,
camera$,
backgroundEffectsState$,
])
.pipe(debounceTime(0), scope.bind())
.subscribe(([audio, video, microphone, camera]) => {
.subscribe(([audio, video, microphone, camera, { wait }]) => {
const open: PublishRequest[] = [];
if (audio && microphone === undefined && !opening.has("microphone"))
open.push(publishRequest("microphone", mediaDevices, capture));
if (video && camera === undefined && !opening.has("camera"))
if (
video &&
camera === undefined &&
!opening.has("camera") &&
wait !== "preparing"
)
open.push(
publishRequest(
"camera",
+12 -4
View File
@@ -21,6 +21,8 @@ import { type Handler } from "./MuteStates";
/** How a mute switch gets a track where there is none. */
export interface TrackSupply {
publish: () => Promise<unknown>;
/** Awaited before a switch turned on acts: a camera waits for its processor to be known. */
ready?: () => Promise<unknown>;
}
/**
@@ -45,6 +47,7 @@ export function followMuteState(
let desired = muteState.enabled$.value;
muteState.setHandler(async (enabled) => {
desired = enabled;
if (enabled) await supply.ready?.();
const track = track$.value;
if (track !== undefined) {
desired = await track.setEnabled(enabled);
@@ -94,18 +97,23 @@ export function followDevice(
/**
* Attached once the camera track exists rather than carried in the publish
* request, so that a new track and a change of processor take the same path.
* Answers with a wait for the latest attachment to have gone through: a
* camera turned on before then would restart without its processor.
*/
export function followProcessor(
scope: ObservableScope,
camera$: Behavior<LocalVideoMediaTrack | undefined>,
videoProcessor$: Behavior<VideoProcessor | undefined>,
logger: Logger,
): void {
): () => Promise<void> {
let attached = Promise.resolve();
combineLatest([camera$, videoProcessor$])
.pipe(scope.bind())
.subscribe(([camera, processor]) => {
camera?.setProcessor(processor).catch((e) => {
logger.error("Failed to set the video processor", e);
});
attached =
camera?.setProcessor(processor).catch((e) => {
logger.error("Failed to set the video processor", e);
}) ?? Promise.resolve();
});
return async () => attached;
}
+37 -2
View File
@@ -13,13 +13,19 @@ import {
} from "@livekit/track-processors";
import { ImageSegmenter } from "@mediapipe/tasks-vision";
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
import {
BackgroundEffectTransformer,
canSegment,
} from "./BackgroundEffectTransformer";
const frame = {} as VideoFrame;
const controller = {} as TransformStreamDefaultController<VideoFrame>;
describe("BackgroundEffectTransformer", () => {
afterEach(() => vi.restoreAllMocks());
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("loads a background chosen before it was built", async () => {
vi.spyOn(VideoTransformer.prototype, "init").mockResolvedValue();
@@ -78,4 +84,33 @@ describe("BackgroundEffectTransformer", () => {
expect(transformer.options.blurRadius).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,
type VideoTransformerInitOptions,
type BackgroundOptions,
type SegmenterOptions,
} from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import { ImageSegmenter } from "@mediapipe/tasks-vision";
@@ -92,17 +93,10 @@ export class BackgroundEffectTransformer extends BackgroundTransformer {
inputElement: inputVideo,
});
this.imageSegmenter = await ImageSegmenter.createFromOptions(wasmFileset, {
baseOptions: {
modelAssetPath,
delegate: "GPU",
...this.options.segmenterOptions,
},
canvas: this.canvas,
runningMode: "VIDEO",
outputCategoryMask: true,
outputConfidenceMasks: false,
});
this.imageSegmenter = await createSegmenter(
this.canvas,
this.options.segmenterOptions,
);
// BackgroundTransformer's own init applies these, and this one replaces it.
if (this.options.imagePath) {
@@ -116,3 +110,38 @@ export class BackgroundEffectTransformer extends BackgroundTransformer {
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
@@ -56,7 +56,8 @@ vi.mock("./backgroundImages", async () => {
};
});
vi.mock("./BackgroundEffectTransformer", () => ({
BackgroundEffectTransformer: vi.fn(),
BackgroundEffectTransformer: vi.fn(function () {}),
canSegment: async (): Promise<boolean> => Promise.resolve(true),
}));
describe("BackgroundEffectsProvider", () => {
+5 -1
View File
@@ -12,7 +12,10 @@ import {
import { createContext, type FC, type JSX, use } from "react";
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
import {
BackgroundEffectTransformer,
canSegment,
} from "./BackgroundEffectTransformer";
import { addedBackgrounds } from "./backgroundImages";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
@@ -56,6 +59,7 @@ export function createAppBackgroundEffects(
effect$: backgroundEffectSetting.value$,
setEffect: backgroundEffectSetting.setValue,
added$: addedBackgrounds.added$,
canSegment,
pipeline: new BackgroundProcessorWrapper(transformer, "background-effect"),
transformer,
});
+1 -1
View File
@@ -217,7 +217,7 @@ export function getBasicCallViewModelEnvironment(
vm,
muteStates,
mediaDevices,
constant(false),
constant({ supported: false, processor: undefined }),
"reactionId",
{ showControls: true, header: HeaderStyle.Standard },
);