@rapidmx/meet-plugin 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -483
  4. package/apps/meet/_CallView.tsx +1060 -924
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_MeetLobby.tsx +224 -224
  8. package/apps/meet/_ParticipantTile.tsx +243 -242
  9. package/apps/meet/_Whiteboard.tsx +577 -0
  10. package/apps/meet/_icons.tsx +135 -90
  11. package/apps/meet/_meetApi.ts +213 -212
  12. package/apps/shared/collab/CollabSession.ts +675 -0
  13. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  14. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  15. package/apps/shared/collab/color.ts +67 -0
  16. package/apps/shared/collab/protocol.ts +321 -0
  17. package/apps/shared/collab/whiteboardRender.ts +311 -0
  18. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  19. package/apps/shared/media/filters/mlModels.ts +174 -174
  20. package/apps/shared/relay/RelayClient.ts +2 -1
  21. package/apps/shared/relay/RelayTransport.ts +2 -1
  22. package/apps/shared/relay/relayEnv.ts +1 -1
  23. package/apps/shared/signaling/SignalingClient.ts +335 -0
  24. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
  25. package/apps/shared/webrtc/types.ts +335 -316
  26. package/dist/apps/meet/[token].js +1 -1
  27. package/dist/apps/meet/_CallControls.d.ts +12 -4
  28. package/dist/apps/meet/_CallControls.js +5 -5
  29. package/dist/apps/meet/_CallView.d.ts +19 -4
  30. package/dist/apps/meet/_CallView.js +100 -11
  31. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  32. package/dist/apps/meet/_ChatPanel.js +116 -0
  33. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  34. package/dist/apps/meet/_ColorWheel.js +82 -0
  35. package/dist/apps/meet/_ParticipantTile.d.ts +2 -1
  36. package/dist/apps/meet/_ParticipantTile.js +1 -1
  37. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  38. package/dist/apps/meet/_Whiteboard.js +312 -0
  39. package/dist/apps/meet/_icons.d.ts +12 -0
  40. package/dist/apps/meet/_icons.js +12 -0
  41. package/dist/apps/meet/_meetApi.d.ts +8 -7
  42. package/dist/apps/meet/_meetApi.js +2 -2
  43. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  44. package/dist/apps/shared/collab/CollabSession.js +559 -0
  45. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  46. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  47. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  48. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  49. package/dist/apps/shared/collab/color.d.ts +18 -0
  50. package/dist/apps/shared/collab/color.js +59 -0
  51. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  52. package/dist/apps/shared/collab/protocol.js +222 -0
  53. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  54. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  55. package/dist/apps/shared/relay/RelayClient.js +2 -1
  56. package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
  57. package/dist/apps/shared/relay/RelayTransport.js +2 -1
  58. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  59. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  60. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  61. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  62. package/dist/apps/shared/webrtc/MeshConnectionManager.js +8 -8
  63. package/dist/apps/shared/webrtc/types.d.ts +38 -24
  64. package/dist/apps/shared/webrtc/types.js +3 -4
  65. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  66. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  67. package/dist/lib/util/SignalingHub.js +339 -0
  68. package/dist/lib/util/SignalingHub.js.map +1 -0
  69. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  70. package/dist/types/util/SignalingHub.d.ts +112 -0
  71. package/package.json +208 -208
  72. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  73. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  74. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -1,408 +1,408 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- /**
6
- * Turns the camera's `MediaStreamTrack` into a *filtered* one: frames from the camera are drawn onto a canvas, put
7
- * through the chosen filters, and the canvas's `captureStream()` track is what the rest of the app sends to the call.
8
- * `useLocalMedia()` owns one of these while any filter is on and swaps it in for the camera track, so the lobby
9
- * preview, the peer connections and the WebSocket relay all get the filtered picture without knowing about it.
10
- *
11
- * Per frame: the background (untouched, or blurred / replaced behind the person-segmentation mask), then the colour
12
- * effect over the whole picture, then the face accessory on top - see `filterTypes.ts`.
13
- *
14
- * - **Fails closed.** A participant who asked for a hidden background must never be shown with their real one - not
15
- * while the segmentation model is still loading, and not if it can't be loaded. Until the mask is available the frame
16
- * is the background alone (the blurred or replacement picture, no person), and `onStatus` reports the load and any
17
- * failure so the UI can say why. Accessories don't matter that way: without the face model there simply is no accessory.
18
- * - **Timing.** Frames are driven by a timer rather than `requestAnimationFrame`, which stops entirely in a
19
- * background tab - the call would freeze for everyone else the moment the participant switched to another window.
20
- * Browsers still slow a background tab's timers, so a filtered picture can drop to a low frame rate there.
21
- * - **The models run at a lower rate than the picture does** (`ML_HOLD_TICKS`), each frame in between reusing the
22
- * last mask/landmarks rather than asking again. Segmentation and face tracking are synchronous, main-thread calls,
23
- * and can each take tens of milliseconds - asking on every rendered frame can starve the main thread badly enough to
24
- * make the WebSocket relay's audio stutter (`AudioSender`/`AudioPlayer` use `ScriptProcessorNode`, which runs its
25
- * callback on the very same thread and glitches audibly if it misses its deadline) - the mask and the face barely
26
- * change from one frame to the next, so asking less often costs nothing anyone can see.
27
- *
28
- * Everything browser-facing (canvases, the video element, the models) comes in through `deps`, so the tests drive a
29
- * processor with fakes.
30
- */
31
- import { type FaceGeometry, type FaceLandmark, drawAccessory, faceGeometry } from "./accessories.js";
32
- import { type Accessory, type VideoFilters, needsFace, needsSegmentation } from "./filterTypes.js";
33
- import { type FaceTracker, type PersonMask, type PersonSegmenter, createFaceTracker, createPersonSegmenter } from "./mlModels.js";
34
- import { applyGrayscale, applyNightVision, applySepia, maskToAlpha } from "./pixelEffects.js";
35
-
36
- /** A picture to show behind the participant. */
37
- export interface BackgroundPicture {
38
- source: CanvasImageSource;
39
- width: number;
40
- height: number;
41
- }
42
-
43
- export interface FilterStatus {
44
- /** A model the current filters need is still downloading. */
45
- loading: boolean;
46
- /** Why a filter isn't fully working, ready to show - `null` when all is well. */
47
- error: string | null;
48
- }
49
-
50
- export interface ProcessorDeps {
51
- createCanvas(): HTMLCanvasElement;
52
- createVideo(): HTMLVideoElement;
53
- createSegmenter(assetsUrl?: string): Promise<PersonSegmenter>;
54
- createFaceTracker(assetsUrl?: string): Promise<FaceTracker>;
55
- now(): number;
56
- }
57
-
58
- export interface VideoFilterProcessorOptions {
59
- /** The camera's track. The processor only reads it - stopping it stays with whoever owns it. */
60
- source: MediaStreamTrack;
61
- filters: VideoFilters;
62
- backgroundImage?: BackgroundPicture | null;
63
- /** Where the models are hosted, when not on the default CDN - see `mlModels.ts`. */
64
- assetsUrl?: string;
65
- onStatus?: (status: FilterStatus) => void;
66
- frameRate?: number;
67
- deps?: Partial<ProcessorDeps>;
68
- }
69
-
70
- const DEFAULT_FRAME_RATE = 30;
71
- /** The background is blurred by drawing the frame at 1/this size and scaling it back up. */
72
- const BLUR_DIVISOR = 12;
73
- /** The picture is pixelated into about this many blocks across. */
74
- const PIXEL_BLOCKS = 80;
75
- /** How many frames in a row without a detected face before the accessory is taken off - a face is briefly lost when
76
- * the head turns, and the accessory shouldn't flicker for that. Counts actual detection attempts, not rendered
77
- * frames - see `ML_HOLD_TICKS`. */
78
- const FACE_HOLD_FRAMES = 6;
79
- /** Segmentation and face tracking actually run on one rendered frame out of this many, reusing the previous
80
- * mask/landmarks the rest of the time - see this module's doc comment. Counted in rendered frames, not wall-clock
81
- * time, so it behaves the same regardless of the configured frame rate or how slow a tick actually runs. */
82
- const ML_HOLD_TICKS = 3;
83
-
84
- const SEGMENTATION_ERROR =
85
- "The background effect couldn't be loaded, so your background stays hidden. Turn the effect off to show your camera.";
86
- const FACE_ERROR = "The face effect couldn't be loaded.";
87
- const RENDER_ERROR = "Video effects stopped working.";
88
-
89
- type ModelState = "idle" | "loading" | "ready" | "failed";
90
-
91
- interface Layer {
92
- canvas: HTMLCanvasElement;
93
- ctx: CanvasRenderingContext2D;
94
- }
95
-
96
- const PIXEL_EFFECTS = { bw: applyGrayscale, sepia: applySepia, "night-vision": applyNightVision };
97
-
98
- const DEFAULT_DEPS: ProcessorDeps = {
99
- createCanvas: () => document.createElement("canvas"),
100
- createVideo: () => document.createElement("video"),
101
- createSegmenter: createPersonSegmenter,
102
- createFaceTracker,
103
- now: () => performance.now(),
104
- };
105
-
106
- function context2d(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
107
- const ctx = canvas.getContext("2d", { willReadFrequently: true });
108
- if (!ctx) {
109
- throw new Error("This browser can't draw video effects.");
110
- }
111
- return ctx;
112
- }
113
-
114
- export class VideoFilterProcessor {
115
- /** The filtered video - what to send in place of the camera's track. */
116
- readonly track: MediaStreamTrack;
117
-
118
- private readonly deps: ProcessorDeps;
119
- private readonly assetsUrl?: string;
120
- private readonly onStatus?: (status: FilterStatus) => void;
121
- private readonly interval: number;
122
- private readonly video: HTMLVideoElement;
123
- private readonly canvas: HTMLCanvasElement;
124
- private readonly ctx: CanvasRenderingContext2D;
125
- private readonly layers = new Map<string, Layer>();
126
-
127
- private filters: VideoFilters;
128
- private backgroundImage: BackgroundPicture | null;
129
- private stopped = false;
130
- private timer: ReturnType<typeof setTimeout> | undefined;
131
-
132
- private segmenter: PersonSegmenter | null = null;
133
- private segmenterState: ModelState = "idle";
134
- private faceTracker: FaceTracker | null = null;
135
- private faceState: ModelState = "idle";
136
- private lastFace: FaceLandmark[] | null = null;
137
- private framesWithoutFace = 0;
138
- private lastMask: PersonMask | null = null;
139
- private segmentTicks = 0;
140
- private faceTicks = 0;
141
- private maskImage: ImageData | null = null;
142
- private renderFailed = false;
143
- private status: FilterStatus = { loading: false, error: null };
144
-
145
- constructor(options: VideoFilterProcessorOptions) {
146
- this.deps = { ...DEFAULT_DEPS, ...options.deps };
147
- this.filters = options.filters;
148
- this.backgroundImage = options.backgroundImage ?? null;
149
- this.assetsUrl = options.assetsUrl;
150
- this.onStatus = options.onStatus;
151
- const frameRate = options.frameRate ?? DEFAULT_FRAME_RATE;
152
- this.interval = 1000 / frameRate;
153
-
154
- this.canvas = this.deps.createCanvas();
155
- this.ctx = context2d(this.canvas);
156
- this.track = this.canvas.captureStream(frameRate).getVideoTracks()[0];
157
-
158
- this.video = this.deps.createVideo();
159
- this.video.muted = true;
160
- this.video.playsInline = true;
161
- this.video.srcObject = new MediaStream([options.source]);
162
- void this.video.play().catch(() => undefined);
163
-
164
- this.syncModels();
165
- this.publishStatus();
166
- this.tick();
167
- }
168
-
169
- /** Changes the filters (and the background picture) without interrupting the video. Also retries a model that failed
170
- * to load, so choosing a filter again is a retry. */
171
- update(filters: VideoFilters, backgroundImage: BackgroundPicture | null): void {
172
- this.filters = filters;
173
- this.backgroundImage = backgroundImage;
174
- this.syncModels(true);
175
- this.publishStatus();
176
- }
177
-
178
- stop(): void {
179
- this.stopped = true;
180
- clearTimeout(this.timer);
181
- this.video.pause();
182
- this.video.srcObject = null;
183
- this.track.stop();
184
- }
185
-
186
- private syncModels(retry = false): void {
187
- if (needsSegmentation(this.filters) && (this.segmenterState === "idle" || (retry && this.segmenterState === "failed"))) {
188
- this.segmenterState = "loading";
189
- this.deps.createSegmenter(this.assetsUrl).then(
190
- (segmenter) => {
191
- this.segmenter = segmenter;
192
- this.segmenterState = "ready";
193
- this.publishStatus();
194
- },
195
- () => {
196
- this.segmenterState = "failed";
197
- this.publishStatus();
198
- },
199
- );
200
- }
201
- if (needsFace(this.filters) && (this.faceState === "idle" || (retry && this.faceState === "failed"))) {
202
- this.faceState = "loading";
203
- this.deps.createFaceTracker(this.assetsUrl).then(
204
- (tracker) => {
205
- this.faceTracker = tracker;
206
- this.faceState = "ready";
207
- this.publishStatus();
208
- },
209
- () => {
210
- this.faceState = "failed";
211
- this.publishStatus();
212
- },
213
- );
214
- }
215
- }
216
-
217
- private publishStatus(): void {
218
- if (this.stopped) {
219
- return;
220
- }
221
- const wantsSegmentation = needsSegmentation(this.filters);
222
- const wantsFace = needsFace(this.filters);
223
- let error: string | null = null;
224
- if (wantsSegmentation && this.segmenterState === "failed") {
225
- error = SEGMENTATION_ERROR;
226
- } else if (wantsFace && this.faceState === "failed") {
227
- error = FACE_ERROR;
228
- } else if (this.renderFailed) {
229
- error = RENDER_ERROR;
230
- }
231
- const loading = (wantsSegmentation && this.segmenterState === "loading") || (wantsFace && this.faceState === "loading");
232
- if (loading !== this.status.loading || error !== this.status.error) {
233
- this.status = { loading, error };
234
- this.onStatus?.(this.status);
235
- }
236
- }
237
-
238
- private tick = (): void => {
239
- if (this.stopped) {
240
- return;
241
- }
242
- const started = this.deps.now();
243
- if (this.video.readyState >= 2 && this.video.videoWidth > 0) {
244
- try {
245
- this.render(started);
246
- if (this.renderFailed) {
247
- // Drawing works again - a single bad frame doesn't leave the warning up.
248
- this.renderFailed = false;
249
- this.publishStatus();
250
- }
251
- } catch {
252
- // A frame that can't be drawn is skipped; the loop carries on, and the participant is told.
253
- if (!this.renderFailed) {
254
- this.renderFailed = true;
255
- this.publishStatus();
256
- }
257
- }
258
- }
259
- this.timer = setTimeout(this.tick, Math.max(0, this.interval - (this.deps.now() - started)));
260
- };
261
-
262
- /** A scratch canvas of the given size, kept between frames. */
263
- private layer(name: string, width: number, height: number): Layer {
264
- let layer = this.layers.get(name);
265
- if (!layer) {
266
- const canvas = this.deps.createCanvas();
267
- layer = { canvas, ctx: context2d(canvas) };
268
- this.layers.set(name, layer);
269
- }
270
- if (layer.canvas.width !== width || layer.canvas.height !== height) {
271
- layer.canvas.width = width;
272
- layer.canvas.height = height;
273
- }
274
- return layer;
275
- }
276
-
277
- private render(now: number): void {
278
- const { video, canvas, ctx, filters } = this;
279
- const width = video.videoWidth;
280
- const height = video.videoHeight;
281
- if (canvas.width !== width || canvas.height !== height) {
282
- canvas.width = width;
283
- canvas.height = height;
284
- }
285
-
286
- if (filters.background === "none") {
287
- ctx.drawImage(video, 0, 0, width, height);
288
- } else {
289
- this.drawBackground(now, width, height);
290
- }
291
- this.applyEffect(width, height);
292
- if (filters.accessory !== "none") {
293
- this.paintAccessory(filters.accessory, now, width, height);
294
- }
295
- }
296
-
297
- /** The background, then - once the segmentation model is ready - the participant on top of it. */
298
- private drawBackground(now: number, width: number, height: number): void {
299
- const { video, ctx } = this;
300
- const picture = this.backgroundImage;
301
- if (this.filters.background === "image" && picture) {
302
- const scale = Math.max(width / picture.width, height / picture.height);
303
- const drawnWidth = picture.width * scale;
304
- const drawnHeight = picture.height * scale;
305
- ctx.drawImage(picture.source, (width - drawnWidth) / 2, (height - drawnHeight) / 2, drawnWidth, drawnHeight);
306
- } else {
307
- // Also the stand-in for a "custom image" background that has no image: blurred, never the real room.
308
- const small = this.layer("blur", Math.ceil(width / BLUR_DIVISOR), Math.ceil(height / BLUR_DIVISOR));
309
- small.ctx.imageSmoothingQuality = "high";
310
- small.ctx.drawImage(video, 0, 0, small.canvas.width, small.canvas.height);
311
- ctx.imageSmoothingQuality = "high";
312
- ctx.drawImage(small.canvas, 0, 0, width, height);
313
- }
314
-
315
- const mask = this.segment(now);
316
- if (!mask) {
317
- return;
318
- }
319
- const maskLayer = this.layer("mask", mask.width, mask.height);
320
- let image = this.maskImage;
321
- if (!image || image.width !== mask.width || image.height !== mask.height) {
322
- image = this.maskImage = maskLayer.ctx.createImageData(mask.width, mask.height);
323
- }
324
- maskToAlpha(mask.confidence, image.data);
325
- maskLayer.ctx.putImageData(image, 0, 0);
326
-
327
- const person = this.layer("person", width, height);
328
- person.ctx.globalCompositeOperation = "source-over";
329
- person.ctx.clearRect(0, 0, width, height);
330
- person.ctx.drawImage(maskLayer.canvas, 0, 0, width, height);
331
- person.ctx.globalCompositeOperation = "source-in";
332
- person.ctx.drawImage(video, 0, 0, width, height);
333
- person.ctx.globalCompositeOperation = "source-over";
334
- ctx.drawImage(person.canvas, 0, 0);
335
- }
336
-
337
- private segment(now: number): PersonMask | null {
338
- if (!this.segmenter) {
339
- return null;
340
- }
341
- const due = this.segmentTicks % ML_HOLD_TICKS === 0;
342
- this.segmentTicks++;
343
- if (!due) {
344
- return this.lastMask;
345
- }
346
- try {
347
- this.lastMask = this.segmenter.segment(this.video, now);
348
- return this.lastMask;
349
- } catch {
350
- this.segmenter = null;
351
- this.segmenterState = "failed";
352
- this.publishStatus();
353
- return null;
354
- }
355
- }
356
-
357
- private applyEffect(width: number, height: number): void {
358
- const { ctx } = this;
359
- const effect = this.filters.effect;
360
- if (effect === "pixelate") {
361
- const small = this.layer("pixel", PIXEL_BLOCKS, Math.ceil((height / width) * PIXEL_BLOCKS));
362
- small.ctx.drawImage(this.canvas, 0, 0, small.canvas.width, small.canvas.height);
363
- ctx.imageSmoothingEnabled = false;
364
- ctx.drawImage(small.canvas, 0, 0, width, height);
365
- ctx.imageSmoothingEnabled = true;
366
- } else if (effect !== "none") {
367
- const pixels = ctx.getImageData(0, 0, width, height);
368
- PIXEL_EFFECTS[effect](pixels.data);
369
- ctx.putImageData(pixels, 0, 0);
370
- }
371
- }
372
-
373
- private paintAccessory(accessory: Exclude<Accessory, "none">, now: number, width: number, height: number): void {
374
- const face = this.trackFace(now);
375
- const geometry: FaceGeometry | null = face ? faceGeometry(face, width, height) : null;
376
- if (geometry) {
377
- drawAccessory(this.ctx, accessory, geometry);
378
- }
379
- }
380
-
381
- /** The face's landmarks for this frame - the last known ones for a few frames after it is lost. */
382
- private trackFace(now: number): FaceLandmark[] | null {
383
- if (!this.faceTracker) {
384
- return null;
385
- }
386
- const due = this.faceTicks % ML_HOLD_TICKS === 0;
387
- this.faceTicks++;
388
- if (!due) {
389
- return this.lastFace;
390
- }
391
- let found: FaceLandmark[] | null = null;
392
- try {
393
- found = this.faceTracker.detect(this.video, now);
394
- } catch {
395
- this.faceTracker = null;
396
- this.faceState = "failed";
397
- this.publishStatus();
398
- return null;
399
- }
400
- if (found) {
401
- this.lastFace = found;
402
- this.framesWithoutFace = 0;
403
- } else if (++this.framesWithoutFace > FACE_HOLD_FRAMES) {
404
- this.lastFace = null;
405
- }
406
- return this.lastFace;
407
- }
408
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Turns the camera's `MediaStreamTrack` into a *filtered* one: frames from the camera are drawn onto a canvas, put
7
+ * through the chosen filters, and the canvas's `captureStream()` track is what the rest of the app sends to the call.
8
+ * `useLocalMedia()` owns one of these while any filter is on and swaps it in for the camera track, so the lobby
9
+ * preview, the peer connections and the WebSocket relay all get the filtered picture without knowing about it.
10
+ *
11
+ * Per frame: the background (untouched, or blurred / replaced behind the person-segmentation mask), then the colour
12
+ * effect over the whole picture, then the face accessory on top - see `filterTypes.ts`.
13
+ *
14
+ * - **Fails closed.** A participant who asked for a hidden background must never be shown with their real one - not
15
+ * while the segmentation model is still loading, and not if it can't be loaded. Until the mask is available the frame
16
+ * is the background alone (the blurred or replacement picture, no person), and `onStatus` reports the load and any
17
+ * failure so the UI can say why. Accessories don't matter that way: without the face model there simply is no accessory.
18
+ * - **Timing.** Frames are driven by a timer rather than `requestAnimationFrame`, which stops entirely in a
19
+ * background tab - the call would freeze for everyone else the moment the participant switched to another window.
20
+ * Browsers still slow a background tab's timers, so a filtered picture can drop to a low frame rate there.
21
+ * - **The models run at a lower rate than the picture does** (`ML_HOLD_TICKS`), each frame in between reusing the
22
+ * last mask/landmarks rather than asking again. Segmentation and face tracking are synchronous, main-thread calls,
23
+ * and can each take tens of milliseconds - asking on every rendered frame can starve the main thread badly enough to
24
+ * make the WebSocket relay's audio stutter (`AudioSender`/`AudioPlayer` use `ScriptProcessorNode`, which runs its
25
+ * callback on the very same thread and glitches audibly if it misses its deadline) - the mask and the face barely
26
+ * change from one frame to the next, so asking less often costs nothing anyone can see.
27
+ *
28
+ * Everything browser-facing (canvases, the video element, the models) comes in through `deps`, so the tests drive a
29
+ * processor with fakes.
30
+ */
31
+ import { type FaceGeometry, type FaceLandmark, drawAccessory, faceGeometry } from "./accessories.js";
32
+ import { type Accessory, type VideoFilters, needsFace, needsSegmentation } from "./filterTypes.js";
33
+ import { type FaceTracker, type PersonMask, type PersonSegmenter, createFaceTracker, createPersonSegmenter } from "./mlModels.js";
34
+ import { applyGrayscale, applyNightVision, applySepia, maskToAlpha } from "./pixelEffects.js";
35
+
36
+ /** A picture to show behind the participant. */
37
+ export interface BackgroundPicture {
38
+ source: CanvasImageSource;
39
+ width: number;
40
+ height: number;
41
+ }
42
+
43
+ export interface FilterStatus {
44
+ /** A model the current filters need is still downloading. */
45
+ loading: boolean;
46
+ /** Why a filter isn't fully working, ready to show - `null` when all is well. */
47
+ error: string | null;
48
+ }
49
+
50
+ export interface ProcessorDeps {
51
+ createCanvas(): HTMLCanvasElement;
52
+ createVideo(): HTMLVideoElement;
53
+ createSegmenter(assetsUrl?: string): Promise<PersonSegmenter>;
54
+ createFaceTracker(assetsUrl?: string): Promise<FaceTracker>;
55
+ now(): number;
56
+ }
57
+
58
+ export interface VideoFilterProcessorOptions {
59
+ /** The camera's track. The processor only reads it - stopping it stays with whoever owns it. */
60
+ source: MediaStreamTrack;
61
+ filters: VideoFilters;
62
+ backgroundImage?: BackgroundPicture | null;
63
+ /** Where the models are hosted, when not on the default CDN - see `mlModels.ts`. */
64
+ assetsUrl?: string;
65
+ onStatus?: (status: FilterStatus) => void;
66
+ frameRate?: number;
67
+ deps?: Partial<ProcessorDeps>;
68
+ }
69
+
70
+ const DEFAULT_FRAME_RATE = 30;
71
+ /** The background is blurred by drawing the frame at 1/this size and scaling it back up. */
72
+ const BLUR_DIVISOR = 12;
73
+ /** The picture is pixelated into about this many blocks across. */
74
+ const PIXEL_BLOCKS = 80;
75
+ /** How many frames in a row without a detected face before the accessory is taken off - a face is briefly lost when
76
+ * the head turns, and the accessory shouldn't flicker for that. Counts actual detection attempts, not rendered
77
+ * frames - see `ML_HOLD_TICKS`. */
78
+ const FACE_HOLD_FRAMES = 6;
79
+ /** Segmentation and face tracking actually run on one rendered frame out of this many, reusing the previous
80
+ * mask/landmarks the rest of the time - see this module's doc comment. Counted in rendered frames, not wall-clock
81
+ * time, so it behaves the same regardless of the configured frame rate or how slow a tick actually runs. */
82
+ const ML_HOLD_TICKS = 3;
83
+
84
+ const SEGMENTATION_ERROR =
85
+ "The background effect couldn't be loaded, so your background stays hidden. Turn the effect off to show your camera.";
86
+ const FACE_ERROR = "The face effect couldn't be loaded.";
87
+ const RENDER_ERROR = "Video effects stopped working.";
88
+
89
+ type ModelState = "idle" | "loading" | "ready" | "failed";
90
+
91
+ interface Layer {
92
+ canvas: HTMLCanvasElement;
93
+ ctx: CanvasRenderingContext2D;
94
+ }
95
+
96
+ const PIXEL_EFFECTS = { bw: applyGrayscale, sepia: applySepia, "night-vision": applyNightVision };
97
+
98
+ const DEFAULT_DEPS: ProcessorDeps = {
99
+ createCanvas: () => document.createElement("canvas"),
100
+ createVideo: () => document.createElement("video"),
101
+ createSegmenter: createPersonSegmenter,
102
+ createFaceTracker,
103
+ now: () => performance.now(),
104
+ };
105
+
106
+ function context2d(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
107
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
108
+ if (!ctx) {
109
+ throw new Error("This browser can't draw video effects.");
110
+ }
111
+ return ctx;
112
+ }
113
+
114
+ export class VideoFilterProcessor {
115
+ /** The filtered video - what to send in place of the camera's track. */
116
+ readonly track: MediaStreamTrack;
117
+
118
+ private readonly deps: ProcessorDeps;
119
+ private readonly assetsUrl?: string;
120
+ private readonly onStatus?: (status: FilterStatus) => void;
121
+ private readonly interval: number;
122
+ private readonly video: HTMLVideoElement;
123
+ private readonly canvas: HTMLCanvasElement;
124
+ private readonly ctx: CanvasRenderingContext2D;
125
+ private readonly layers = new Map<string, Layer>();
126
+
127
+ private filters: VideoFilters;
128
+ private backgroundImage: BackgroundPicture | null;
129
+ private stopped = false;
130
+ private timer: ReturnType<typeof setTimeout> | undefined;
131
+
132
+ private segmenter: PersonSegmenter | null = null;
133
+ private segmenterState: ModelState = "idle";
134
+ private faceTracker: FaceTracker | null = null;
135
+ private faceState: ModelState = "idle";
136
+ private lastFace: FaceLandmark[] | null = null;
137
+ private framesWithoutFace = 0;
138
+ private lastMask: PersonMask | null = null;
139
+ private segmentTicks = 0;
140
+ private faceTicks = 0;
141
+ private maskImage: ImageData | null = null;
142
+ private renderFailed = false;
143
+ private status: FilterStatus = { loading: false, error: null };
144
+
145
+ constructor(options: VideoFilterProcessorOptions) {
146
+ this.deps = { ...DEFAULT_DEPS, ...options.deps };
147
+ this.filters = options.filters;
148
+ this.backgroundImage = options.backgroundImage ?? null;
149
+ this.assetsUrl = options.assetsUrl;
150
+ this.onStatus = options.onStatus;
151
+ const frameRate = options.frameRate ?? DEFAULT_FRAME_RATE;
152
+ this.interval = 1000 / frameRate;
153
+
154
+ this.canvas = this.deps.createCanvas();
155
+ this.ctx = context2d(this.canvas);
156
+ this.track = this.canvas.captureStream(frameRate).getVideoTracks()[0];
157
+
158
+ this.video = this.deps.createVideo();
159
+ this.video.muted = true;
160
+ this.video.playsInline = true;
161
+ this.video.srcObject = new MediaStream([options.source]);
162
+ void this.video.play().catch(() => undefined);
163
+
164
+ this.syncModels();
165
+ this.publishStatus();
166
+ this.tick();
167
+ }
168
+
169
+ /** Changes the filters (and the background picture) without interrupting the video. Also retries a model that failed
170
+ * to load, so choosing a filter again is a retry. */
171
+ update(filters: VideoFilters, backgroundImage: BackgroundPicture | null): void {
172
+ this.filters = filters;
173
+ this.backgroundImage = backgroundImage;
174
+ this.syncModels(true);
175
+ this.publishStatus();
176
+ }
177
+
178
+ stop(): void {
179
+ this.stopped = true;
180
+ clearTimeout(this.timer);
181
+ this.video.pause();
182
+ this.video.srcObject = null;
183
+ this.track.stop();
184
+ }
185
+
186
+ private syncModels(retry = false): void {
187
+ if (needsSegmentation(this.filters) && (this.segmenterState === "idle" || (retry && this.segmenterState === "failed"))) {
188
+ this.segmenterState = "loading";
189
+ this.deps.createSegmenter(this.assetsUrl).then(
190
+ (segmenter) => {
191
+ this.segmenter = segmenter;
192
+ this.segmenterState = "ready";
193
+ this.publishStatus();
194
+ },
195
+ () => {
196
+ this.segmenterState = "failed";
197
+ this.publishStatus();
198
+ },
199
+ );
200
+ }
201
+ if (needsFace(this.filters) && (this.faceState === "idle" || (retry && this.faceState === "failed"))) {
202
+ this.faceState = "loading";
203
+ this.deps.createFaceTracker(this.assetsUrl).then(
204
+ (tracker) => {
205
+ this.faceTracker = tracker;
206
+ this.faceState = "ready";
207
+ this.publishStatus();
208
+ },
209
+ () => {
210
+ this.faceState = "failed";
211
+ this.publishStatus();
212
+ },
213
+ );
214
+ }
215
+ }
216
+
217
+ private publishStatus(): void {
218
+ if (this.stopped) {
219
+ return;
220
+ }
221
+ const wantsSegmentation = needsSegmentation(this.filters);
222
+ const wantsFace = needsFace(this.filters);
223
+ let error: string | null = null;
224
+ if (wantsSegmentation && this.segmenterState === "failed") {
225
+ error = SEGMENTATION_ERROR;
226
+ } else if (wantsFace && this.faceState === "failed") {
227
+ error = FACE_ERROR;
228
+ } else if (this.renderFailed) {
229
+ error = RENDER_ERROR;
230
+ }
231
+ const loading = (wantsSegmentation && this.segmenterState === "loading") || (wantsFace && this.faceState === "loading");
232
+ if (loading !== this.status.loading || error !== this.status.error) {
233
+ this.status = { loading, error };
234
+ this.onStatus?.(this.status);
235
+ }
236
+ }
237
+
238
+ private tick = (): void => {
239
+ if (this.stopped) {
240
+ return;
241
+ }
242
+ const started = this.deps.now();
243
+ if (this.video.readyState >= 2 && this.video.videoWidth > 0) {
244
+ try {
245
+ this.render(started);
246
+ if (this.renderFailed) {
247
+ // Drawing works again - a single bad frame doesn't leave the warning up.
248
+ this.renderFailed = false;
249
+ this.publishStatus();
250
+ }
251
+ } catch {
252
+ // A frame that can't be drawn is skipped; the loop carries on, and the participant is told.
253
+ if (!this.renderFailed) {
254
+ this.renderFailed = true;
255
+ this.publishStatus();
256
+ }
257
+ }
258
+ }
259
+ this.timer = setTimeout(this.tick, Math.max(0, this.interval - (this.deps.now() - started)));
260
+ };
261
+
262
+ /** A scratch canvas of the given size, kept between frames. */
263
+ private layer(name: string, width: number, height: number): Layer {
264
+ let layer = this.layers.get(name);
265
+ if (!layer) {
266
+ const canvas = this.deps.createCanvas();
267
+ layer = { canvas, ctx: context2d(canvas) };
268
+ this.layers.set(name, layer);
269
+ }
270
+ if (layer.canvas.width !== width || layer.canvas.height !== height) {
271
+ layer.canvas.width = width;
272
+ layer.canvas.height = height;
273
+ }
274
+ return layer;
275
+ }
276
+
277
+ private render(now: number): void {
278
+ const { video, canvas, ctx, filters } = this;
279
+ const width = video.videoWidth;
280
+ const height = video.videoHeight;
281
+ if (canvas.width !== width || canvas.height !== height) {
282
+ canvas.width = width;
283
+ canvas.height = height;
284
+ }
285
+
286
+ if (filters.background === "none") {
287
+ ctx.drawImage(video, 0, 0, width, height);
288
+ } else {
289
+ this.drawBackground(now, width, height);
290
+ }
291
+ this.applyEffect(width, height);
292
+ if (filters.accessory !== "none") {
293
+ this.paintAccessory(filters.accessory, now, width, height);
294
+ }
295
+ }
296
+
297
+ /** The background, then - once the segmentation model is ready - the participant on top of it. */
298
+ private drawBackground(now: number, width: number, height: number): void {
299
+ const { video, ctx } = this;
300
+ const picture = this.backgroundImage;
301
+ if (this.filters.background === "image" && picture) {
302
+ const scale = Math.max(width / picture.width, height / picture.height);
303
+ const drawnWidth = picture.width * scale;
304
+ const drawnHeight = picture.height * scale;
305
+ ctx.drawImage(picture.source, (width - drawnWidth) / 2, (height - drawnHeight) / 2, drawnWidth, drawnHeight);
306
+ } else {
307
+ // Also the stand-in for a "custom image" background that has no image: blurred, never the real room.
308
+ const small = this.layer("blur", Math.ceil(width / BLUR_DIVISOR), Math.ceil(height / BLUR_DIVISOR));
309
+ small.ctx.imageSmoothingQuality = "high";
310
+ small.ctx.drawImage(video, 0, 0, small.canvas.width, small.canvas.height);
311
+ ctx.imageSmoothingQuality = "high";
312
+ ctx.drawImage(small.canvas, 0, 0, width, height);
313
+ }
314
+
315
+ const mask = this.segment(now);
316
+ if (!mask) {
317
+ return;
318
+ }
319
+ const maskLayer = this.layer("mask", mask.width, mask.height);
320
+ let image = this.maskImage;
321
+ if (!image || image.width !== mask.width || image.height !== mask.height) {
322
+ image = this.maskImage = maskLayer.ctx.createImageData(mask.width, mask.height);
323
+ }
324
+ maskToAlpha(mask.confidence, image.data);
325
+ maskLayer.ctx.putImageData(image, 0, 0);
326
+
327
+ const person = this.layer("person", width, height);
328
+ person.ctx.globalCompositeOperation = "source-over";
329
+ person.ctx.clearRect(0, 0, width, height);
330
+ person.ctx.drawImage(maskLayer.canvas, 0, 0, width, height);
331
+ person.ctx.globalCompositeOperation = "source-in";
332
+ person.ctx.drawImage(video, 0, 0, width, height);
333
+ person.ctx.globalCompositeOperation = "source-over";
334
+ ctx.drawImage(person.canvas, 0, 0);
335
+ }
336
+
337
+ private segment(now: number): PersonMask | null {
338
+ if (!this.segmenter) {
339
+ return null;
340
+ }
341
+ const due = this.segmentTicks % ML_HOLD_TICKS === 0;
342
+ this.segmentTicks++;
343
+ if (!due) {
344
+ return this.lastMask;
345
+ }
346
+ try {
347
+ this.lastMask = this.segmenter.segment(this.video, now);
348
+ return this.lastMask;
349
+ } catch {
350
+ this.segmenter = null;
351
+ this.segmenterState = "failed";
352
+ this.publishStatus();
353
+ return null;
354
+ }
355
+ }
356
+
357
+ private applyEffect(width: number, height: number): void {
358
+ const { ctx } = this;
359
+ const effect = this.filters.effect;
360
+ if (effect === "pixelate") {
361
+ const small = this.layer("pixel", PIXEL_BLOCKS, Math.ceil((height / width) * PIXEL_BLOCKS));
362
+ small.ctx.drawImage(this.canvas, 0, 0, small.canvas.width, small.canvas.height);
363
+ ctx.imageSmoothingEnabled = false;
364
+ ctx.drawImage(small.canvas, 0, 0, width, height);
365
+ ctx.imageSmoothingEnabled = true;
366
+ } else if (effect !== "none") {
367
+ const pixels = ctx.getImageData(0, 0, width, height);
368
+ PIXEL_EFFECTS[effect](pixels.data);
369
+ ctx.putImageData(pixels, 0, 0);
370
+ }
371
+ }
372
+
373
+ private paintAccessory(accessory: Exclude<Accessory, "none">, now: number, width: number, height: number): void {
374
+ const face = this.trackFace(now);
375
+ const geometry: FaceGeometry | null = face ? faceGeometry(face, width, height) : null;
376
+ if (geometry) {
377
+ drawAccessory(this.ctx, accessory, geometry);
378
+ }
379
+ }
380
+
381
+ /** The face's landmarks for this frame - the last known ones for a few frames after it is lost. */
382
+ private trackFace(now: number): FaceLandmark[] | null {
383
+ if (!this.faceTracker) {
384
+ return null;
385
+ }
386
+ const due = this.faceTicks % ML_HOLD_TICKS === 0;
387
+ this.faceTicks++;
388
+ if (!due) {
389
+ return this.lastFace;
390
+ }
391
+ let found: FaceLandmark[] | null = null;
392
+ try {
393
+ found = this.faceTracker.detect(this.video, now);
394
+ } catch {
395
+ this.faceTracker = null;
396
+ this.faceState = "failed";
397
+ this.publishStatus();
398
+ return null;
399
+ }
400
+ if (found) {
401
+ this.lastFace = found;
402
+ this.framesWithoutFace = 0;
403
+ } else if (++this.framesWithoutFace > FACE_HOLD_FRAMES) {
404
+ this.lastFace = null;
405
+ }
406
+ return this.lastFace;
407
+ }
408
+ }