mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-07 00:52:28 +00:00
Merge pull request #4285 from element-hq/spec/background-effects/5-slow-and-wait
Say where background effects run slowly, and show the wait for the first
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"blur": "Blur",
|
||||
"none": "None",
|
||||
"numbered": "Background {{n}}",
|
||||
"slow": "Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
"unavailable": "Background effects are not supported on this platform."
|
||||
},
|
||||
"call_ended_view": {
|
||||
|
||||
@@ -136,6 +136,7 @@ export const Default: Story = {
|
||||
})),
|
||||
],
|
||||
backgroundEffectNotice: undefined,
|
||||
backgroundEffectSettling: false,
|
||||
hangup: fn(),
|
||||
buttonSize: "lg",
|
||||
showFooter: true,
|
||||
|
||||
@@ -78,7 +78,9 @@ export interface FooterState {
|
||||
/** Every effect on offer, in the order they are shown. */
|
||||
backgroundEffects: BackgroundEffectGridItem[];
|
||||
/** What the user is told about effects here, if anything. */
|
||||
backgroundEffectNotice: "unavailable" | undefined;
|
||||
backgroundEffectNotice: "unavailable" | "slow" | undefined;
|
||||
/** Whether the first effect chosen is still being prepared. */
|
||||
backgroundEffectSettling: boolean;
|
||||
showFooter: boolean;
|
||||
|
||||
/* This is needed for WindowMode = "flat" */
|
||||
@@ -165,6 +167,7 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const backgroundEffect = useBehavior(vm.backgroundEffect$);
|
||||
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
|
||||
const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$);
|
||||
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
|
||||
const backgroundEffects = useBehavior(vm.backgroundEffects$);
|
||||
const buttonSize = useBehavior(vm.buttonSize$);
|
||||
const showLogo = useBehavior(vm.showLogo$);
|
||||
@@ -230,10 +233,13 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
backgroundEffects={backgroundEffects}
|
||||
selectedBackgroundEffect={backgroundEffect}
|
||||
onSelectBackgroundEffect={selectBackgroundEffect}
|
||||
backgroundEffectSettling={backgroundEffectSettling}
|
||||
backgroundEffectNotice={
|
||||
backgroundEffectNotice === "unavailable"
|
||||
? t("background_effects.unavailable")
|
||||
: undefined
|
||||
: backgroundEffectNotice === "slow"
|
||||
? t("background_effects.slow")
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
|
||||
import { constant } from "../state/Behavior";
|
||||
@@ -30,8 +31,10 @@ vi.mock("../Platform", () => ({
|
||||
|
||||
// The SDK's own check needs WebGL and canvas APIs jsdom does not have.
|
||||
const sdkSupportMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const modernRouteMock = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock("@livekit/track-processors", () => ({
|
||||
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
|
||||
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
|
||||
}));
|
||||
|
||||
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
|
||||
@@ -109,6 +112,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -151,6 +155,7 @@ describe("createCallFooterViewModel", () => {
|
||||
select: vi.fn(),
|
||||
},
|
||||
}),
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -169,6 +174,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(layout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -191,6 +197,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
@@ -236,18 +243,19 @@ describe("createCallFooterViewModel", () => {
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
}
|
||||
|
||||
it("offers nothing on a phone, where the pipeline would refuse them", () => {
|
||||
it("offers them on a phone whose browser can run them", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("ios").selectBackgroundEffect$.value).toBeUndefined();
|
||||
expect(lobbyOn("ios").selectBackgroundEffect$.value).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers them where the pipeline will honour them", () => {
|
||||
it("offers them on a desktop whose browser can run them", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeDefined();
|
||||
});
|
||||
@@ -275,6 +283,16 @@ describe("createCallFooterViewModel", () => {
|
||||
expect(backgroundEffectSetting.getValue()).toBe("none");
|
||||
});
|
||||
|
||||
it("says they run slowly where only the slower route exists", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
modernRouteMock.mockReturnValue(false);
|
||||
const slow = lobbyOn("desktop");
|
||||
expect(slow.backgroundEffectNotice$.value).toBe("slow");
|
||||
expect(slow.selectBackgroundEffect$.value).toBeDefined();
|
||||
modernRouteMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers every effect in order", () => {
|
||||
sdkSupportMock.mockReturnValue(true);
|
||||
expect(lobbyOn("desktop").backgroundEffects$.value).toEqual([
|
||||
@@ -295,6 +313,23 @@ describe("createCallFooterViewModel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes on the wait for the first effect", () => {
|
||||
platformMock.mockReturnValue("desktop");
|
||||
const settling$ = new BehaviorSubject(true);
|
||||
const vm = createLobbyFooterViewModel(
|
||||
testScope(),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
settling$,
|
||||
/* openSettings */ undefined,
|
||||
/* hangup */ undefined,
|
||||
/* showLogo */ false,
|
||||
);
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(true);
|
||||
settling$.next(false);
|
||||
expect(vm.backgroundEffectSettling$.value).toBe(false);
|
||||
});
|
||||
|
||||
it("availability is the same before and during a call", () => {
|
||||
for (const supported of [true, false]) {
|
||||
sdkSupportMock.mockReturnValue(supported);
|
||||
@@ -304,6 +339,7 @@ describe("createCallFooterViewModel", () => {
|
||||
buildMinimalCallViewModel(gridLayout),
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
constant(false),
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
@@ -5,10 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, type Observable, switchMap } from "rxjs";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { supportsAudioOutputSelection } from "livekit-client";
|
||||
import {
|
||||
supportsBackgroundProcessors,
|
||||
supportsModernBackgroundProcessors,
|
||||
} from "@livekit/track-processors";
|
||||
|
||||
import { supportsBackgroundProcessors } from "../tracks/backgroundProcessing";
|
||||
import {
|
||||
parseEffect,
|
||||
serializeEffect,
|
||||
@@ -70,6 +79,7 @@ function buildDeviceBehaviors(
|
||||
mediaDevices: MediaDevices,
|
||||
/** return empty arrays for audioOptions and videoOptions*/
|
||||
disableSwitcher$: Behavior<boolean>,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
): Pick<
|
||||
ViewModel<FooterSnapshot>,
|
||||
| "audioOptions$"
|
||||
@@ -85,6 +95,7 @@ function buildDeviceBehaviors(
|
||||
| "selectBackgroundEffect$"
|
||||
| "backgroundEffects$"
|
||||
| "backgroundEffectNotice$"
|
||||
| "backgroundEffectSettling$"
|
||||
> {
|
||||
const options$ = (
|
||||
available$: Behavior<Map<string, MenuOptions["label"]>>,
|
||||
@@ -102,6 +113,7 @@ function buildDeviceBehaviors(
|
||||
);
|
||||
|
||||
const supported = supportsBackgroundProcessors();
|
||||
const slow = !supportsModernBackgroundProcessors();
|
||||
const offered$ = disableSwitcher$.pipe(
|
||||
map((switcherDisabled) => !switcherDisabled && supported),
|
||||
);
|
||||
@@ -148,7 +160,15 @@ function buildDeviceBehaviors(
|
||||
),
|
||||
backgroundEffects$: constant(backgroundEffectChoices()),
|
||||
backgroundEffectNotice$: scope.behavior(
|
||||
offered$.pipe(map((offered) => (offered ? undefined : "unavailable"))),
|
||||
offered$.pipe(
|
||||
map((offered) =>
|
||||
!offered ? "unavailable" : slow ? "slow" : undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
backgroundEffectSettling$: scope.behavior(
|
||||
backgroundEffectSettling$.pipe(distinctUntilChanged()),
|
||||
false,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -160,6 +180,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 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.
|
||||
@@ -170,6 +192,7 @@ export function createCallFooterViewModel(
|
||||
callModel: CallViewModel,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
reactionIdentifier: string | undefined,
|
||||
options: { showControls: boolean; header: HeaderStyle },
|
||||
): ViewModel<FooterSnapshot> {
|
||||
@@ -184,7 +207,12 @@ export function createCallFooterViewModel(
|
||||
);
|
||||
return {
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
mediaDevices,
|
||||
disableDeviceSwitcher$,
|
||||
backgroundEffectSettling$,
|
||||
),
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
hideControls$: constant(!showControls),
|
||||
@@ -246,6 +274,8 @@ 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 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.
|
||||
@@ -254,6 +284,7 @@ export function createLobbyFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
backgroundEffectSettling$: Observable<boolean>,
|
||||
openSettings: (() => void) | undefined,
|
||||
hangup: (() => void) | undefined,
|
||||
showLogo: boolean,
|
||||
@@ -297,7 +328,12 @@ export function createLobbyFooterViewModel(
|
||||
selectVideoButtonOption: undefined,
|
||||
}),
|
||||
...buildMuteBehaviors(scope, muteStates),
|
||||
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
|
||||
...buildDeviceBehaviors(
|
||||
scope,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
backgroundEffectSettling$,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -274,6 +274,73 @@ export const BackgroundEffectsUnavailable: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Where only the slower route exists: offered and choosable, with the cost said. */
|
||||
export const BackgroundEffectsSlowInThisBrowser: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
backgroundEffectNotice:
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
const section = await menu.findByRole("group", {
|
||||
name: "Background effects",
|
||||
});
|
||||
// On the tiles' own group: the section's can't carry a description.
|
||||
await expect(
|
||||
within(section).getByRole("group"),
|
||||
).toHaveAccessibleDescription(
|
||||
"Background effects run slowly on this platform, which may cause your video to stutter.",
|
||||
);
|
||||
for (const tile of within(section).getAllByRole("menuitemradio"))
|
||||
await expect(tile).not.toHaveAttribute("aria-disabled");
|
||||
await userEvent.click(
|
||||
within(section).getByRole("menuitemradio", { name: "Background 1" }),
|
||||
);
|
||||
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith(
|
||||
"image:arc",
|
||||
);
|
||||
|
||||
const notice = document.body.querySelector(`.${styles.notice}`)!;
|
||||
const frame = document.body
|
||||
.querySelector("[role='menu']")!
|
||||
.getBoundingClientRect();
|
||||
await expect(frame.width).toBeLessThanOrEqual(296);
|
||||
await expect(notice.getBoundingClientRect().right).toBeLessThanOrEqual(
|
||||
frame.right,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The first effect of a session shows its wait on its own tile. */
|
||||
export const BackgroundEffectsSettling: Story = {
|
||||
args: {
|
||||
...VideoUnmute.args,
|
||||
selectedBackgroundEffect: "blur",
|
||||
backgroundEffectSettling: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
|
||||
const menu = within(document.body);
|
||||
const chosen = await menu.findByRole("menuitemradio", {
|
||||
name: "Blur",
|
||||
checked: true,
|
||||
});
|
||||
await expect(chosen).toHaveAttribute("aria-busy", "true");
|
||||
const marks = chosen.querySelectorAll("svg");
|
||||
await expect(marks).toHaveLength(1);
|
||||
await expect(getComputedStyle(marks[0]).animationName).not.toBe("none");
|
||||
const section = menu.getByRole("group", { name: "Background effects" });
|
||||
for (const other of within(section).getAllByRole("menuitemradio", {
|
||||
checked: false,
|
||||
}))
|
||||
await expect(other).toHaveAttribute("aria-busy", "false");
|
||||
},
|
||||
};
|
||||
|
||||
/** In a short call the effects scroll into view with the list. */
|
||||
export const BackgroundEffectsScrollWhenTheyDoNotFit: Story = {
|
||||
args: {
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface MediaMuteAndSwitchButtonProps {
|
||||
onSelectBackgroundEffect?: (id: string) => void;
|
||||
/** What the user should know before choosing an effect, if anything. */
|
||||
backgroundEffectNotice?: string;
|
||||
/** Whether the first effect chosen is still being prepared. */
|
||||
backgroundEffectSettling?: boolean;
|
||||
/**
|
||||
* 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`
|
||||
@@ -110,6 +112,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
selectedBackgroundEffect,
|
||||
onSelectBackgroundEffect,
|
||||
backgroundEffectNotice,
|
||||
backgroundEffectSettling,
|
||||
onSelect,
|
||||
}) => {
|
||||
// Requested but not yet selected. Keyed by kind too, since Chrome uses
|
||||
@@ -387,6 +390,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
|
||||
effects={backgroundEffects ?? []}
|
||||
selected={selectedBackgroundEffect}
|
||||
onSelect={onSelectBackgroundEffect}
|
||||
settling={backgroundEffectSettling}
|
||||
describedBy={
|
||||
backgroundEffectNotice === undefined ? undefined : noticeId
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
vm,
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)),
|
||||
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
||||
{ showControls: urlParams.showControls, header: urlParams.header },
|
||||
);
|
||||
|
||||
@@ -164,6 +164,7 @@ export const LobbyView: FC<Props> = ({
|
||||
footerScope,
|
||||
muteStates,
|
||||
devices,
|
||||
backgroundEffectsState$.pipe(map(({ settling }) => settling ?? false)),
|
||||
openSettings,
|
||||
hangup,
|
||||
// Logo and header are connected: only show the logo in SPA with header.
|
||||
@@ -173,7 +174,14 @@ export const LobbyView: FC<Props> = ({
|
||||
return (): void => {
|
||||
footerScope.end();
|
||||
};
|
||||
}, [devices, hangup, hideHeader, muteStates, openSettings]);
|
||||
}, [
|
||||
devices,
|
||||
backgroundEffectsState$,
|
||||
hangup,
|
||||
hideHeader,
|
||||
muteStates,
|
||||
openSettings,
|
||||
]);
|
||||
|
||||
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||
// animations and don't have to feel bad about reusing its CSS
|
||||
|
||||
@@ -46,25 +46,33 @@ function fakePipeline(): {
|
||||
};
|
||||
}
|
||||
|
||||
/** One letter per state: idle, attached. */
|
||||
/** One letter per state: idle, waiting for a frame, attached. */
|
||||
function letter(state: BackgroundEffectsState): string {
|
||||
return state.processor === undefined ? "i" : "a";
|
||||
if (state.processor === undefined) return "i";
|
||||
return state.settling ? "w" : "a";
|
||||
}
|
||||
|
||||
describe("the pipeline's state", () => {
|
||||
function testState({
|
||||
effect,
|
||||
firstFrame = "",
|
||||
expected,
|
||||
}: {
|
||||
effect: string;
|
||||
firstFrame?: string;
|
||||
expected: string;
|
||||
}): void {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
const transformer = {
|
||||
onFirstFrame: undefined as (() => void) | undefined,
|
||||
};
|
||||
const effects = createBackgroundEffects(testScope(), {
|
||||
supported: true,
|
||||
effect$: behavior(effect, { n: "none", b: "blur" }),
|
||||
pipeline: fakePipeline().pipeline,
|
||||
transformer,
|
||||
});
|
||||
schedule(firstFrame, { f: () => transformer.onFirstFrame?.() });
|
||||
expectObservable(
|
||||
effects.state$.pipe(map(letter), distinctUntilChanged()),
|
||||
).toBe(expected);
|
||||
@@ -73,8 +81,15 @@ describe("the pipeline's state", () => {
|
||||
|
||||
it("defaults to no effect", () => testState({ effect: "n", expected: "i" }));
|
||||
|
||||
it("waits from the first effect until a frame carries it", () =>
|
||||
testState({
|
||||
effect: " nb-n-b",
|
||||
firstFrame: " --f",
|
||||
expected: " iwa",
|
||||
}));
|
||||
|
||||
it("attaches on first use and stays attached", () =>
|
||||
testState({ effect: "nbn", expected: "ia" }));
|
||||
testState({ effect: "nb-n", firstFrame: "--f", expected: "iwa" }));
|
||||
});
|
||||
|
||||
describe("background effects", () => {
|
||||
@@ -88,6 +103,7 @@ describe("background effects", () => {
|
||||
supported: true,
|
||||
effect$,
|
||||
pipeline: fake.pipeline,
|
||||
transformer: { onFirstFrame: undefined },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, distinctUntilChanged, map, scan } from "rxjs";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
Observable,
|
||||
scan,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type BackgroundProcessorWrapper,
|
||||
type SwitchBackgroundProcessorOptions,
|
||||
@@ -30,6 +36,8 @@ 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;
|
||||
}
|
||||
|
||||
export interface BackgroundEffectsOptions {
|
||||
@@ -39,6 +47,8 @@ export interface BackgroundEffectsOptions {
|
||||
effect$: Behavior<string>;
|
||||
/** The background processor pipeline to be switched between effects. */
|
||||
pipeline: BackgroundProcessorWrapper;
|
||||
/** Tells, once, that a frame carrying an effect has been drawn. */
|
||||
transformer: { onFirstFrame: (() => void) | undefined };
|
||||
}
|
||||
|
||||
/** The background effect pipeline, as the camera tracks and the menus see it. */
|
||||
@@ -49,7 +59,7 @@ 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 }: BackgroundEffectsOptions,
|
||||
{ supported, effect$, pipeline, transformer }: BackgroundEffectsOptions,
|
||||
): BackgroundEffects {
|
||||
// Wrapped once, so the call and the preview see one processor throughout.
|
||||
const processor = fromLivekitProcessor(pipeline);
|
||||
@@ -59,15 +69,29 @@ export function createBackgroundEffects(
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
const drewAFrame$ = scope.behavior(
|
||||
new Observable<boolean>((subscriber) => {
|
||||
subscriber.next(false);
|
||||
transformer.onFirstFrame = (): void => subscriber.next(true);
|
||||
return (): void => {
|
||||
transformer.onFirstFrame = undefined;
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const state$ = scope.behavior(
|
||||
wanted$.pipe(
|
||||
scan<boolean, BackgroundEffectsState>(
|
||||
(previous, wanted) => {
|
||||
combineLatest([wanted$, drewAFrame$]).pipe(
|
||||
scan<[boolean, boolean], BackgroundEffectsState>(
|
||||
(previous, [wanted, 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);
|
||||
return { supported, processor: enable ? processor : undefined };
|
||||
return {
|
||||
supported,
|
||||
processor: enable ? processor : undefined,
|
||||
settling: enable && !drewAFrame,
|
||||
};
|
||||
},
|
||||
{ supported, processor: undefined },
|
||||
),
|
||||
|
||||
@@ -15,6 +15,9 @@ import { ImageSegmenter } from "@mediapipe/tasks-vision";
|
||||
|
||||
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
|
||||
|
||||
const frame = {} as VideoFrame;
|
||||
const controller = {} as TransformStreamDefaultController<VideoFrame>;
|
||||
|
||||
describe("BackgroundEffectTransformer", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -38,6 +41,32 @@ describe("BackgroundEffectTransformer", () => {
|
||||
expect(load).toHaveBeenCalledWith("/background.jpg");
|
||||
});
|
||||
|
||||
it("reports the first frame carrying an effect, once", async () => {
|
||||
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
|
||||
async function (this: BackgroundTransformer) {
|
||||
this.isFirstFrame = false;
|
||||
return Promise.resolve();
|
||||
},
|
||||
);
|
||||
const transformer = new BackgroundEffectTransformer({});
|
||||
transformer.onFirstFrame = vi.fn();
|
||||
|
||||
await transformer.transform(frame, controller);
|
||||
await transformer.transform(frame, controller);
|
||||
expect(transformer.onFirstFrame).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not report a frame passed through untouched", async () => {
|
||||
vi.spyOn(BackgroundTransformer.prototype, "transform").mockResolvedValue();
|
||||
const transformer = new BackgroundEffectTransformer({
|
||||
backgroundDisabled: true,
|
||||
});
|
||||
transformer.onFirstFrame = vi.fn();
|
||||
|
||||
await transformer.transform(frame, controller);
|
||||
expect(transformer.onFirstFrame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes frames on untouched once disabled, even after blurring", async () => {
|
||||
const transformer = new BackgroundEffectTransformer({});
|
||||
await transformer.update({ blurRadius: 15, backgroundDisabled: false });
|
||||
|
||||
@@ -53,6 +53,20 @@ const wasmFileset: WasmFileset = {
|
||||
* resource fetched from the public internet.
|
||||
*/
|
||||
export class BackgroundEffectTransformer extends BackgroundTransformer {
|
||||
/** Called once, when the first frame carrying an effect has been drawn. */
|
||||
public onFirstFrame: (() => void) | undefined;
|
||||
|
||||
public override async transform(
|
||||
frame: VideoFrame,
|
||||
controller: TransformStreamDefaultController<VideoFrame>,
|
||||
): Promise<void> {
|
||||
const priming = this.isFirstFrame;
|
||||
await super.transform(frame, controller);
|
||||
// Cleared only once a frame has been through the segmenter; a frame
|
||||
// passed through untouched leaves it set.
|
||||
if (priming && !this.isFirstFrame) this.onFirstFrame?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* As the library's, except that disabling also drops the blur radius. The
|
||||
* library skips a frame on `isDisabled ?? backgroundDisabled`, and
|
||||
|
||||
@@ -93,13 +93,13 @@ describe("BackgroundEffectsProvider", () => {
|
||||
expect(latest().processor).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not run on a phone, even where its browser could", async () => {
|
||||
it("runs on a phone whose browser can run it", async () => {
|
||||
platformMock.platform = "ios";
|
||||
render(surface());
|
||||
await blur(true);
|
||||
|
||||
expect(latest().supported).toBe(false);
|
||||
expect(latest().processor).toBeUndefined();
|
||||
expect(latest().supported).toBe(true);
|
||||
expect(latest().processor).toBeDefined();
|
||||
});
|
||||
|
||||
it("switches to an effect chosen while the camera is off", async () => {
|
||||
|
||||
@@ -5,12 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { BackgroundProcessorWrapper } from "@livekit/track-processors";
|
||||
import {
|
||||
BackgroundProcessorWrapper,
|
||||
supportsBackgroundProcessors,
|
||||
} from "@livekit/track-processors";
|
||||
import { createContext, type FC, type JSX, use } from "react";
|
||||
|
||||
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
|
||||
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
|
||||
import { supportsBackgroundProcessors } from "./backgroundProcessing";
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type ObservableScope } from "../state/ObservableScope";
|
||||
import {
|
||||
@@ -45,13 +47,14 @@ export function useBackgroundEffectsState$(): Behavior<BackgroundEffectsState> {
|
||||
export function createAppBackgroundEffects(
|
||||
scope: ObservableScope,
|
||||
): BackgroundEffects {
|
||||
const transformer = new BackgroundEffectTransformer({
|
||||
backgroundDisabled: true,
|
||||
});
|
||||
return createBackgroundEffects(scope, {
|
||||
supported: supportsBackgroundProcessors(),
|
||||
effect$: backgroundEffectSetting.value$,
|
||||
pipeline: new BackgroundProcessorWrapper(
|
||||
new BackgroundEffectTransformer({ backgroundDisabled: true }),
|
||||
"background-effect",
|
||||
),
|
||||
pipeline: new BackgroundProcessorWrapper(transformer, "background-effect"),
|
||||
transformer,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/*
|
||||
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 { supportsBackgroundProcessors as supportsBackgroundProcessorsLivekitSdk } from "@livekit/track-processors";
|
||||
|
||||
import { platform } from "../Platform";
|
||||
|
||||
/** One answer for the controls and the pipeline, so neither offers what the other refuses. */
|
||||
export function supportsBackgroundProcessors(): boolean {
|
||||
return supportsBackgroundProcessorsLivekitSdk() && platform === "desktop";
|
||||
}
|
||||
@@ -217,6 +217,7 @@ export function getBasicCallViewModelEnvironment(
|
||||
vm,
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
constant(false),
|
||||
"reactionId",
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user