diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 15eafbe86..5944a59c3 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -1746,6 +1746,43 @@ describe("a camera with a video processor", () => { 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({ + 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((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 () => { diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index dae39e1d3..114a4feba 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -533,6 +533,19 @@ export function createCallViewModel$( "screenShare", ) as Behavior; + 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 => { + await processorKnown(); + await processorAttached(); + }; const supply = (source: "microphone" | "camera"): TrackSupply => ({ publish: async () => rtcParticipation.publish( @@ -543,7 +556,7 @@ export function createCallViewModel$( source === "camera" ? videoProcessor$.value : undefined, ), ), - ready: source === "camera" ? processorKnown : undefined, + ready: source === "camera" ? cameraReady : undefined, }); followMuteState( scope, @@ -606,8 +619,6 @@ export function createCallViewModel$( }); }); - followProcessor(scope, camera$, videoProcessor$, logger); - const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>( combineLatest( [rtcParticipation.localMember$, rtcParticipation.remoteMembers$], diff --git a/src/state/localMediaControls.ts b/src/state/localMediaControls.ts index 580d4f100..f22617597 100644 --- a/src/state/localMediaControls.ts +++ b/src/state/localMediaControls.ts @@ -97,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, videoProcessor$: Behavior, logger: Logger, -): void { +): () => Promise { + 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; }