Turn a camera back on rather than replace it for its processor

The SDK now builds a processor chosen while the camera was off when the
camera turns on, so the call view model no longer unpublishes the
camera and publishes a new one. TrackSupply.replace, the record of
which camera took which processor, and followProcessor's applied
callback go with it.

Answers robintown's review on #4282 ("what's wrong with attaching
processors to already published camera tracks?").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Timo K.
2026-11-04 19:49:07 +01:00
co-authored by Claude Opus 5.5
parent 2d9b76ac1b
commit b04237fada
3 changed files with 7 additions and 68 deletions
+3 -26
View File
@@ -1697,7 +1697,9 @@ describe("a camera with a video processor", () => {
).toMatchObject({ processor });
});
it("replaces a camera turned off before the processor was chosen", async () => {
// 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>(
@@ -1711,31 +1713,6 @@ describe("a camera with a video processor", () => {
},
{ videoProcessor$ },
);
// The camera is off, so it is stopped and can't take the processor
videoProcessor$.next(processor);
await cameraHandler(setHandler)(true);
await flushPromises();
expect(participation!.unpublish).toHaveBeenCalledWith("camera");
expect(participation!.publish).toHaveBeenCalledWith(
expect.objectContaining({ source: "camera", processor }),
);
});
it("turns on a camera that took the processor while running", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const videoProcessor$ = new BehaviorSubject<VideoProcessor | undefined>(
undefined,
);
let participation: RTCParticipation | undefined;
withCallViewModel(
{ videoEnabled: new Map([[localParticipant, constant(true)]]) },
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ videoProcessor$ },
);
videoProcessor$.next(processor);
await cameraHandler(setHandler)(true);
await flushPromises();
+1 -28
View File
@@ -14,7 +14,6 @@ import {
type EncryptionSystem,
generateItems,
type LocalAudioMediaTrack,
type LocalMediaTrack,
type LocalRTCMember,
type LocalVideoMediaTrack,
type RTCParticipation,
@@ -513,16 +512,6 @@ export function createCallViewModel$(
"screenShare",
) as Behavior<LocalVideoMediaTrack | undefined>;
// Camera tracks the processor reached while they ran. A camera turned off is
// stopped, and a stopped track can't take one.
const tookProcessor = new WeakMap<LocalVideoMediaTrack, VideoProcessor>();
const missesProcessor = (track: LocalMediaTrack): boolean => {
const processor = videoProcessor$.value;
return (
processor !== undefined &&
tookProcessor.get(track as LocalVideoMediaTrack) !== processor
);
};
const supply = (source: "microphone" | "camera"): TrackSupply => ({
publish: async () =>
rtcParticipation.publish(
@@ -533,16 +522,6 @@ export function createCallViewModel$(
source === "camera" ? videoProcessor$.value : undefined,
),
),
// A camera turned off before the processor was chosen is replaced, so
// that the new one starts with it
replace:
source === "camera"
? async (track): Promise<boolean> => {
if (!missesProcessor(track)) return false;
await rtcParticipation.unpublish("camera");
return true;
}
: undefined,
});
followMuteState(
scope,
@@ -582,13 +561,7 @@ export function createCallViewModel$(
});
});
followProcessor(
scope,
camera$,
videoProcessor$,
logger,
(camera, processor) => tookProcessor.set(camera, processor),
);
followProcessor(scope, camera$, videoProcessor$, logger);
const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>(
combineLatest(
+3 -14
View File
@@ -18,14 +18,9 @@ import { combineLatest } from "rxjs";
import { type MediaDevice } from "./MediaDevices";
import { type Handler } from "./MuteStates";
/**
* How a mute switch gets a track where there is none and, where given,
* whether a track it has should be let go and published anew instead of
* unmuted: resolves true once it is gone.
*/
/** How a mute switch gets a track where there is none. */
export interface TrackSupply {
publish: () => Promise<unknown>;
replace?: (track: LocalMediaTrack) => Promise<boolean>;
}
/**
@@ -51,9 +46,7 @@ export function followMuteState(
muteState.setHandler(async (enabled) => {
desired = enabled;
const track = track$.value;
const replaced =
enabled && track !== undefined && (await supply.replace?.(track));
if (track !== undefined && !replaced) {
if (track !== undefined) {
desired = await track.setEnabled(enabled);
return desired;
}
@@ -107,15 +100,11 @@ export function followProcessor(
camera$: Behavior<LocalVideoMediaTrack | undefined>,
videoProcessor$: Behavior<VideoProcessor | undefined>,
logger: Logger,
/** Told of each camera a processor reaches while it runs. */
applied?: (camera: LocalVideoMediaTrack, processor: VideoProcessor) => void,
): void {
combineLatest([camera$, videoProcessor$])
.pipe(scope.bind())
.subscribe(([camera, processor]) => {
if (camera === undefined) return;
if (processor && !camera.muted$.value) applied?.(camera, processor);
camera.setProcessor(processor).catch((e) => {
camera?.setProcessor(processor).catch((e) => {
logger.error("Failed to set the video processor", e);
});
});