@rapidmx/meet-plugin 0.5.0 → 0.8.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 (110) hide show
  1. package/README.md +15 -2
  2. package/apps/meet/[token].tsx +3 -2
  3. package/apps/meet/_CallControls.tsx +28 -2
  4. package/apps/meet/_CallView.tsx +23 -2
  5. package/apps/meet/_EffectsPanel.tsx +134 -0
  6. package/apps/meet/_MeetLobby.tsx +22 -0
  7. package/apps/meet/_ParticipantTile.tsx +62 -9
  8. package/apps/meet/_icons.tsx +4 -0
  9. package/apps/meet/_meetApi.ts +6 -0
  10. package/apps/shared/media/filters/VideoFilterProcessor.ts +383 -0
  11. package/apps/shared/media/filters/accessories.ts +262 -0
  12. package/apps/shared/media/filters/backgroundImage.ts +69 -0
  13. package/apps/shared/media/filters/filterTypes.ts +89 -0
  14. package/apps/shared/media/filters/mlModels.ts +174 -0
  15. package/apps/shared/media/filters/pixelEffects.ts +61 -0
  16. package/apps/shared/media/mediaPreferences.ts +105 -0
  17. package/apps/shared/media/useLocalMedia.ts +198 -14
  18. package/apps/shared/relay/AudioPlayer.ts +111 -0
  19. package/apps/shared/relay/AudioSender.ts +194 -0
  20. package/apps/shared/relay/RelayClient.ts +262 -0
  21. package/apps/shared/relay/RelayReceiver.ts +72 -0
  22. package/apps/shared/relay/RelaySender.ts +74 -0
  23. package/apps/shared/relay/RelayTransport.ts +188 -0
  24. package/apps/shared/relay/VideoPlayer.ts +110 -0
  25. package/apps/shared/relay/VideoSender.ts +216 -0
  26. package/apps/shared/relay/audioResume.ts +55 -0
  27. package/apps/shared/relay/frames.ts +275 -0
  28. package/apps/shared/relay/relayEnv.ts +230 -0
  29. package/apps/shared/relay/senderCommon.ts +30 -0
  30. package/apps/shared/webrtc/MeshConnectionManager.ts +158 -6
  31. package/apps/shared/webrtc/realPeerConnection.ts +22 -0
  32. package/apps/shared/webrtc/types.ts +32 -2
  33. package/dist/apps/meet/[token].js +3 -3
  34. package/dist/apps/meet/_CallControls.d.ts +2 -1
  35. package/dist/apps/meet/_CallControls.js +6 -3
  36. package/dist/apps/meet/_CallView.d.ts +4 -1
  37. package/dist/apps/meet/_CallView.js +17 -3
  38. package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
  39. package/dist/apps/meet/_EffectsPanel.js +60 -0
  40. package/dist/apps/meet/_MeetLobby.d.ts +4 -0
  41. package/dist/apps/meet/_MeetLobby.js +8 -1
  42. package/dist/apps/meet/_ParticipantTile.d.ts +7 -1
  43. package/dist/apps/meet/_ParticipantTile.js +20 -3
  44. package/dist/apps/meet/_icons.d.ts +1 -0
  45. package/dist/apps/meet/_icons.js +1 -0
  46. package/dist/apps/meet/_meetApi.d.ts +6 -0
  47. package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +75 -0
  48. package/dist/apps/shared/media/filters/VideoFilterProcessor.js +304 -0
  49. package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
  50. package/dist/apps/shared/media/filters/accessories.js +211 -0
  51. package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
  52. package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
  53. package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
  54. package/dist/apps/shared/media/filters/filterTypes.js +63 -0
  55. package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
  56. package/dist/apps/shared/media/filters/mlModels.js +106 -0
  57. package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
  58. package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
  59. package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
  60. package/dist/apps/shared/media/mediaPreferences.js +93 -0
  61. package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
  62. package/dist/apps/shared/media/useLocalMedia.js +160 -14
  63. package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
  64. package/dist/apps/shared/relay/AudioPlayer.js +103 -0
  65. package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
  66. package/dist/apps/shared/relay/AudioSender.js +172 -0
  67. package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
  68. package/dist/apps/shared/relay/RelayClient.js +236 -0
  69. package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
  70. package/dist/apps/shared/relay/RelayReceiver.js +59 -0
  71. package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
  72. package/dist/apps/shared/relay/RelaySender.js +54 -0
  73. package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
  74. package/dist/apps/shared/relay/RelayTransport.js +142 -0
  75. package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
  76. package/dist/apps/shared/relay/VideoPlayer.js +102 -0
  77. package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
  78. package/dist/apps/shared/relay/VideoSender.js +204 -0
  79. package/dist/apps/shared/relay/audioResume.d.ts +12 -0
  80. package/dist/apps/shared/relay/audioResume.js +45 -0
  81. package/dist/apps/shared/relay/frames.d.ts +101 -0
  82. package/dist/apps/shared/relay/frames.js +221 -0
  83. package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
  84. package/dist/apps/shared/relay/relayEnv.js +69 -0
  85. package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
  86. package/dist/apps/shared/relay/senderCommon.js +10 -0
  87. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +51 -1
  88. package/dist/apps/shared/webrtc/MeshConnectionManager.js +137 -6
  89. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  90. package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
  91. package/dist/apps/shared/webrtc/types.d.ts +29 -2
  92. package/dist/lib/index.js +2 -0
  93. package/dist/lib/index.js.map +1 -1
  94. package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
  95. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  96. package/dist/lib/util/EffectsUtils.js +21 -0
  97. package/dist/lib/util/EffectsUtils.js.map +1 -0
  98. package/dist/lib/util/IceServerUtils.js +25 -4
  99. package/dist/lib/util/IceServerUtils.js.map +1 -1
  100. package/dist/lib/util/RedisRelayBus.js +241 -0
  101. package/dist/lib/util/RedisRelayBus.js.map +1 -0
  102. package/dist/lib/util/RelayHub.js +405 -0
  103. package/dist/lib/util/RelayHub.js.map +1 -0
  104. package/dist/types/index.d.ts +2 -0
  105. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
  106. package/dist/types/util/EffectsUtils.d.ts +10 -0
  107. package/dist/types/util/IceServerUtils.d.ts +12 -1
  108. package/dist/types/util/RedisRelayBus.d.ts +131 -0
  109. package/dist/types/util/RelayHub.d.ts +188 -0
  110. package/package.json +24 -7
@@ -0,0 +1,383 @@
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
+ *
22
+ * Everything browser-facing (canvases, the video element, the models) comes in through `deps`, so the tests drive a
23
+ * processor with fakes.
24
+ */
25
+ import { type FaceGeometry, type FaceLandmark, drawAccessory, faceGeometry } from "./accessories.js";
26
+ import { type Accessory, type VideoFilters, needsFace, needsSegmentation } from "./filterTypes.js";
27
+ import { type FaceTracker, type PersonMask, type PersonSegmenter, createFaceTracker, createPersonSegmenter } from "./mlModels.js";
28
+ import { applyGrayscale, applyNightVision, applySepia, maskToAlpha } from "./pixelEffects.js";
29
+
30
+ /** A picture to show behind the participant. */
31
+ export interface BackgroundPicture {
32
+ source: CanvasImageSource;
33
+ width: number;
34
+ height: number;
35
+ }
36
+
37
+ export interface FilterStatus {
38
+ /** A model the current filters need is still downloading. */
39
+ loading: boolean;
40
+ /** Why a filter isn't fully working, ready to show - `null` when all is well. */
41
+ error: string | null;
42
+ }
43
+
44
+ export interface ProcessorDeps {
45
+ createCanvas(): HTMLCanvasElement;
46
+ createVideo(): HTMLVideoElement;
47
+ createSegmenter(assetsUrl?: string): Promise<PersonSegmenter>;
48
+ createFaceTracker(assetsUrl?: string): Promise<FaceTracker>;
49
+ now(): number;
50
+ }
51
+
52
+ export interface VideoFilterProcessorOptions {
53
+ /** The camera's track. The processor only reads it - stopping it stays with whoever owns it. */
54
+ source: MediaStreamTrack;
55
+ filters: VideoFilters;
56
+ backgroundImage?: BackgroundPicture | null;
57
+ /** Where the models are hosted, when not on the default CDN - see `mlModels.ts`. */
58
+ assetsUrl?: string;
59
+ onStatus?: (status: FilterStatus) => void;
60
+ frameRate?: number;
61
+ deps?: Partial<ProcessorDeps>;
62
+ }
63
+
64
+ const DEFAULT_FRAME_RATE = 30;
65
+ /** The background is blurred by drawing the frame at 1/this size and scaling it back up. */
66
+ const BLUR_DIVISOR = 12;
67
+ /** The picture is pixelated into about this many blocks across. */
68
+ const PIXEL_BLOCKS = 80;
69
+ /** How many frames in a row without a detected face before the accessory is taken off - a face is briefly lost when
70
+ * the head turns, and the accessory shouldn't flicker for that. */
71
+ const FACE_HOLD_FRAMES = 6;
72
+
73
+ const SEGMENTATION_ERROR =
74
+ "The background effect couldn't be loaded, so your background stays hidden. Turn the effect off to show your camera.";
75
+ const FACE_ERROR = "The face effect couldn't be loaded.";
76
+ const RENDER_ERROR = "Video effects stopped working.";
77
+
78
+ type ModelState = "idle" | "loading" | "ready" | "failed";
79
+
80
+ interface Layer {
81
+ canvas: HTMLCanvasElement;
82
+ ctx: CanvasRenderingContext2D;
83
+ }
84
+
85
+ const PIXEL_EFFECTS = { bw: applyGrayscale, sepia: applySepia, "night-vision": applyNightVision };
86
+
87
+ const DEFAULT_DEPS: ProcessorDeps = {
88
+ createCanvas: () => document.createElement("canvas"),
89
+ createVideo: () => document.createElement("video"),
90
+ createSegmenter: createPersonSegmenter,
91
+ createFaceTracker,
92
+ now: () => performance.now(),
93
+ };
94
+
95
+ function context2d(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
96
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
97
+ if (!ctx) {
98
+ throw new Error("This browser can't draw video effects.");
99
+ }
100
+ return ctx;
101
+ }
102
+
103
+ export class VideoFilterProcessor {
104
+ /** The filtered video - what to send in place of the camera's track. */
105
+ readonly track: MediaStreamTrack;
106
+
107
+ private readonly deps: ProcessorDeps;
108
+ private readonly assetsUrl?: string;
109
+ private readonly onStatus?: (status: FilterStatus) => void;
110
+ private readonly interval: number;
111
+ private readonly video: HTMLVideoElement;
112
+ private readonly canvas: HTMLCanvasElement;
113
+ private readonly ctx: CanvasRenderingContext2D;
114
+ private readonly layers = new Map<string, Layer>();
115
+
116
+ private filters: VideoFilters;
117
+ private backgroundImage: BackgroundPicture | null;
118
+ private stopped = false;
119
+ private timer: ReturnType<typeof setTimeout> | undefined;
120
+
121
+ private segmenter: PersonSegmenter | null = null;
122
+ private segmenterState: ModelState = "idle";
123
+ private faceTracker: FaceTracker | null = null;
124
+ private faceState: ModelState = "idle";
125
+ private lastFace: FaceLandmark[] | null = null;
126
+ private framesWithoutFace = 0;
127
+ private maskImage: ImageData | null = null;
128
+ private renderFailed = false;
129
+ private status: FilterStatus = { loading: false, error: null };
130
+
131
+ constructor(options: VideoFilterProcessorOptions) {
132
+ this.deps = { ...DEFAULT_DEPS, ...options.deps };
133
+ this.filters = options.filters;
134
+ this.backgroundImage = options.backgroundImage ?? null;
135
+ this.assetsUrl = options.assetsUrl;
136
+ this.onStatus = options.onStatus;
137
+ const frameRate = options.frameRate ?? DEFAULT_FRAME_RATE;
138
+ this.interval = 1000 / frameRate;
139
+
140
+ this.canvas = this.deps.createCanvas();
141
+ this.ctx = context2d(this.canvas);
142
+ this.track = this.canvas.captureStream(frameRate).getVideoTracks()[0];
143
+
144
+ this.video = this.deps.createVideo();
145
+ this.video.muted = true;
146
+ this.video.playsInline = true;
147
+ this.video.srcObject = new MediaStream([options.source]);
148
+ void this.video.play().catch(() => undefined);
149
+
150
+ this.syncModels();
151
+ this.publishStatus();
152
+ this.tick();
153
+ }
154
+
155
+ /** Changes the filters (and the background picture) without interrupting the video. Also retries a model that failed
156
+ * to load, so choosing a filter again is a retry. */
157
+ update(filters: VideoFilters, backgroundImage: BackgroundPicture | null): void {
158
+ this.filters = filters;
159
+ this.backgroundImage = backgroundImage;
160
+ this.syncModels(true);
161
+ this.publishStatus();
162
+ }
163
+
164
+ stop(): void {
165
+ this.stopped = true;
166
+ clearTimeout(this.timer);
167
+ this.video.pause();
168
+ this.video.srcObject = null;
169
+ this.track.stop();
170
+ }
171
+
172
+ private syncModels(retry = false): void {
173
+ if (needsSegmentation(this.filters) && (this.segmenterState === "idle" || (retry && this.segmenterState === "failed"))) {
174
+ this.segmenterState = "loading";
175
+ this.deps.createSegmenter(this.assetsUrl).then(
176
+ (segmenter) => {
177
+ this.segmenter = segmenter;
178
+ this.segmenterState = "ready";
179
+ this.publishStatus();
180
+ },
181
+ () => {
182
+ this.segmenterState = "failed";
183
+ this.publishStatus();
184
+ },
185
+ );
186
+ }
187
+ if (needsFace(this.filters) && (this.faceState === "idle" || (retry && this.faceState === "failed"))) {
188
+ this.faceState = "loading";
189
+ this.deps.createFaceTracker(this.assetsUrl).then(
190
+ (tracker) => {
191
+ this.faceTracker = tracker;
192
+ this.faceState = "ready";
193
+ this.publishStatus();
194
+ },
195
+ () => {
196
+ this.faceState = "failed";
197
+ this.publishStatus();
198
+ },
199
+ );
200
+ }
201
+ }
202
+
203
+ private publishStatus(): void {
204
+ if (this.stopped) {
205
+ return;
206
+ }
207
+ const wantsSegmentation = needsSegmentation(this.filters);
208
+ const wantsFace = needsFace(this.filters);
209
+ let error: string | null = null;
210
+ if (wantsSegmentation && this.segmenterState === "failed") {
211
+ error = SEGMENTATION_ERROR;
212
+ } else if (wantsFace && this.faceState === "failed") {
213
+ error = FACE_ERROR;
214
+ } else if (this.renderFailed) {
215
+ error = RENDER_ERROR;
216
+ }
217
+ const loading = (wantsSegmentation && this.segmenterState === "loading") || (wantsFace && this.faceState === "loading");
218
+ if (loading !== this.status.loading || error !== this.status.error) {
219
+ this.status = { loading, error };
220
+ this.onStatus?.(this.status);
221
+ }
222
+ }
223
+
224
+ private tick = (): void => {
225
+ if (this.stopped) {
226
+ return;
227
+ }
228
+ const started = this.deps.now();
229
+ if (this.video.readyState >= 2 && this.video.videoWidth > 0) {
230
+ try {
231
+ this.render(started);
232
+ if (this.renderFailed) {
233
+ // Drawing works again - a single bad frame doesn't leave the warning up.
234
+ this.renderFailed = false;
235
+ this.publishStatus();
236
+ }
237
+ } catch {
238
+ // A frame that can't be drawn is skipped; the loop carries on, and the participant is told.
239
+ if (!this.renderFailed) {
240
+ this.renderFailed = true;
241
+ this.publishStatus();
242
+ }
243
+ }
244
+ }
245
+ this.timer = setTimeout(this.tick, Math.max(0, this.interval - (this.deps.now() - started)));
246
+ };
247
+
248
+ /** A scratch canvas of the given size, kept between frames. */
249
+ private layer(name: string, width: number, height: number): Layer {
250
+ let layer = this.layers.get(name);
251
+ if (!layer) {
252
+ const canvas = this.deps.createCanvas();
253
+ layer = { canvas, ctx: context2d(canvas) };
254
+ this.layers.set(name, layer);
255
+ }
256
+ if (layer.canvas.width !== width || layer.canvas.height !== height) {
257
+ layer.canvas.width = width;
258
+ layer.canvas.height = height;
259
+ }
260
+ return layer;
261
+ }
262
+
263
+ private render(now: number): void {
264
+ const { video, canvas, ctx, filters } = this;
265
+ const width = video.videoWidth;
266
+ const height = video.videoHeight;
267
+ if (canvas.width !== width || canvas.height !== height) {
268
+ canvas.width = width;
269
+ canvas.height = height;
270
+ }
271
+
272
+ if (filters.background === "none") {
273
+ ctx.drawImage(video, 0, 0, width, height);
274
+ } else {
275
+ this.drawBackground(now, width, height);
276
+ }
277
+ this.applyEffect(width, height);
278
+ if (filters.accessory !== "none") {
279
+ this.paintAccessory(filters.accessory, now, width, height);
280
+ }
281
+ }
282
+
283
+ /** The background, then - once the segmentation model is ready - the participant on top of it. */
284
+ private drawBackground(now: number, width: number, height: number): void {
285
+ const { video, ctx } = this;
286
+ const picture = this.backgroundImage;
287
+ if (this.filters.background === "image" && picture) {
288
+ const scale = Math.max(width / picture.width, height / picture.height);
289
+ const drawnWidth = picture.width * scale;
290
+ const drawnHeight = picture.height * scale;
291
+ ctx.drawImage(picture.source, (width - drawnWidth) / 2, (height - drawnHeight) / 2, drawnWidth, drawnHeight);
292
+ } else {
293
+ // Also the stand-in for a "custom image" background that has no image: blurred, never the real room.
294
+ const small = this.layer("blur", Math.ceil(width / BLUR_DIVISOR), Math.ceil(height / BLUR_DIVISOR));
295
+ small.ctx.imageSmoothingQuality = "high";
296
+ small.ctx.drawImage(video, 0, 0, small.canvas.width, small.canvas.height);
297
+ ctx.imageSmoothingQuality = "high";
298
+ ctx.drawImage(small.canvas, 0, 0, width, height);
299
+ }
300
+
301
+ const mask = this.segment(now);
302
+ if (!mask) {
303
+ return;
304
+ }
305
+ const maskLayer = this.layer("mask", mask.width, mask.height);
306
+ let image = this.maskImage;
307
+ if (!image || image.width !== mask.width || image.height !== mask.height) {
308
+ image = this.maskImage = maskLayer.ctx.createImageData(mask.width, mask.height);
309
+ }
310
+ maskToAlpha(mask.confidence, image.data);
311
+ maskLayer.ctx.putImageData(image, 0, 0);
312
+
313
+ const person = this.layer("person", width, height);
314
+ person.ctx.globalCompositeOperation = "source-over";
315
+ person.ctx.clearRect(0, 0, width, height);
316
+ person.ctx.drawImage(maskLayer.canvas, 0, 0, width, height);
317
+ person.ctx.globalCompositeOperation = "source-in";
318
+ person.ctx.drawImage(video, 0, 0, width, height);
319
+ person.ctx.globalCompositeOperation = "source-over";
320
+ ctx.drawImage(person.canvas, 0, 0);
321
+ }
322
+
323
+ private segment(now: number): PersonMask | null {
324
+ if (!this.segmenter) {
325
+ return null;
326
+ }
327
+ try {
328
+ return this.segmenter.segment(this.video, now);
329
+ } catch {
330
+ this.segmenter = null;
331
+ this.segmenterState = "failed";
332
+ this.publishStatus();
333
+ return null;
334
+ }
335
+ }
336
+
337
+ private applyEffect(width: number, height: number): void {
338
+ const { ctx } = this;
339
+ const effect = this.filters.effect;
340
+ if (effect === "pixelate") {
341
+ const small = this.layer("pixel", PIXEL_BLOCKS, Math.ceil((height / width) * PIXEL_BLOCKS));
342
+ small.ctx.drawImage(this.canvas, 0, 0, small.canvas.width, small.canvas.height);
343
+ ctx.imageSmoothingEnabled = false;
344
+ ctx.drawImage(small.canvas, 0, 0, width, height);
345
+ ctx.imageSmoothingEnabled = true;
346
+ } else if (effect !== "none") {
347
+ const pixels = ctx.getImageData(0, 0, width, height);
348
+ PIXEL_EFFECTS[effect](pixels.data);
349
+ ctx.putImageData(pixels, 0, 0);
350
+ }
351
+ }
352
+
353
+ private paintAccessory(accessory: Exclude<Accessory, "none">, now: number, width: number, height: number): void {
354
+ const face = this.trackFace(now);
355
+ const geometry: FaceGeometry | null = face ? faceGeometry(face, width, height) : null;
356
+ if (geometry) {
357
+ drawAccessory(this.ctx, accessory, geometry);
358
+ }
359
+ }
360
+
361
+ /** The face's landmarks for this frame - the last known ones for a few frames after it is lost. */
362
+ private trackFace(now: number): FaceLandmark[] | null {
363
+ if (!this.faceTracker) {
364
+ return null;
365
+ }
366
+ let found: FaceLandmark[] | null = null;
367
+ try {
368
+ found = this.faceTracker.detect(this.video, now);
369
+ } catch {
370
+ this.faceTracker = null;
371
+ this.faceState = "failed";
372
+ this.publishStatus();
373
+ return null;
374
+ }
375
+ if (found) {
376
+ this.lastFace = found;
377
+ this.framesWithoutFace = 0;
378
+ } else if (++this.framesWithoutFace > FACE_HOLD_FRAMES) {
379
+ this.lastFace = null;
380
+ }
381
+ return this.lastFace;
382
+ }
383
+ }
@@ -0,0 +1,262 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The face accessories - sunglasses, cat ears, a party hat, a crown, a mustache - drawn as vector paths onto the
7
+ * filtered frame, so the plugin ships no image assets at all.
8
+ *
9
+ * Each one is drawn in the face's own coordinate frame: the origin sits on a landmark (between the eyes, the top of
10
+ * the forehead, the nose), the x axis runs along the line between the eyes so the accessory tilts with the head, and
11
+ * one unit is the distance between the outer corners of the eyes so it grows and shrinks with the head. That keeps
12
+ * every drawing routine below a few plain numbers in "face widths".
13
+ */
14
+ import type { Accessory } from "./filterTypes.js";
15
+
16
+ /** A landmark as the face model reports it: 0-1 across and down the frame. */
17
+ export interface FaceLandmark {
18
+ x: number;
19
+ y: number;
20
+ }
21
+
22
+ export interface Point {
23
+ x: number;
24
+ y: number;
25
+ }
26
+
27
+ /** Where the parts of a face are, in canvas pixels. */
28
+ export interface FaceGeometry {
29
+ /** Halfway between the outer corners of the eyes. */
30
+ eyes: Point;
31
+ /** The top of the forehead. */
32
+ forehead: Point;
33
+ noseTip: Point;
34
+ upperLip: Point;
35
+ /** The distance between the outer corners of the eyes - one unit of the accessories' coordinate frame. */
36
+ unit: number;
37
+ /** The tilt of the line between the eyes, in radians. */
38
+ angle: number;
39
+ }
40
+
41
+ // Indices into the 478-point face mesh (MediaPipe's canonical face model).
42
+ const RIGHT_EYE_OUTER = 33;
43
+ const LEFT_EYE_OUTER = 263;
44
+ const FOREHEAD_TOP = 10;
45
+ const NOSE_TIP = 1;
46
+ const UPPER_LIP = 0;
47
+ const MESH_POINTS = 468;
48
+
49
+ /** Works out where the parts of a face are, in pixels, from the face mesh's landmarks - `null` when there are not
50
+ * enough of them to be a face mesh at all. */
51
+ export function faceGeometry(landmarks: FaceLandmark[], width: number, height: number): FaceGeometry | null {
52
+ if (landmarks.length < MESH_POINTS) {
53
+ return null;
54
+ }
55
+ const at = (index: number): Point => ({ x: landmarks[index].x * width, y: landmarks[index].y * height });
56
+ const right = at(RIGHT_EYE_OUTER);
57
+ const left = at(LEFT_EYE_OUTER);
58
+ return {
59
+ eyes: { x: (right.x + left.x) / 2, y: (right.y + left.y) / 2 },
60
+ forehead: at(FOREHEAD_TOP),
61
+ noseTip: at(NOSE_TIP),
62
+ upperLip: at(UPPER_LIP),
63
+ unit: Math.hypot(left.x - right.x, left.y - right.y),
64
+ angle: Math.atan2(left.y - right.y, left.x - right.x),
65
+ };
66
+ }
67
+
68
+ /** Runs `draw` with the canvas moved to `origin`, turned by the face's tilt and scaled to one face width per unit. */
69
+ function inFaceFrame(ctx: CanvasRenderingContext2D, origin: Point, face: FaceGeometry, draw: () => void): void {
70
+ ctx.save();
71
+ ctx.translate(origin.x, origin.y);
72
+ ctx.rotate(face.angle);
73
+ ctx.scale(face.unit, face.unit);
74
+ draw();
75
+ ctx.restore();
76
+ }
77
+
78
+ /** Runs `draw` once as it is and once flipped left-right, for the two matching halves of a symmetrical accessory. */
79
+ function bothSides(ctx: CanvasRenderingContext2D, draw: () => void): void {
80
+ for (const side of [1, -1]) {
81
+ ctx.save();
82
+ ctx.scale(side, 1);
83
+ draw();
84
+ ctx.restore();
85
+ }
86
+ }
87
+
88
+ function roundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
89
+ ctx.beginPath();
90
+ ctx.moveTo(x + r, y);
91
+ ctx.arcTo(x + w, y, x + w, y + h, r);
92
+ ctx.arcTo(x + w, y + h, x, y + h, r);
93
+ ctx.arcTo(x, y + h, x, y, r);
94
+ ctx.arcTo(x, y, x + w, y, r);
95
+ ctx.closePath();
96
+ }
97
+
98
+ function drawSunglasses(ctx: CanvasRenderingContext2D, face: FaceGeometry): void {
99
+ inFaceFrame(ctx, face.eyes, face, () => {
100
+ ctx.fillStyle = "rgba(12, 12, 18, 0.93)";
101
+ ctx.strokeStyle = "rgba(12, 12, 18, 0.93)";
102
+ ctx.lineCap = "round";
103
+ bothSides(ctx, () => {
104
+ roundedRect(ctx, 0.06, -0.17, 0.54, 0.38, 0.12);
105
+ ctx.fill();
106
+ // The arm that runs back over the ear.
107
+ ctx.lineWidth = 0.04;
108
+ ctx.beginPath();
109
+ ctx.moveTo(0.6, -0.06);
110
+ ctx.lineTo(0.8, -0.1);
111
+ ctx.stroke();
112
+ // A glint across the lens.
113
+ ctx.fillStyle = "rgba(255, 255, 255, 0.2)";
114
+ ctx.beginPath();
115
+ ctx.moveTo(0.14, -0.1);
116
+ ctx.lineTo(0.28, -0.1);
117
+ ctx.lineTo(0.16, 0.12);
118
+ ctx.lineTo(0.1, 0.12);
119
+ ctx.closePath();
120
+ ctx.fill();
121
+ });
122
+ // The bridge between the lenses.
123
+ ctx.lineWidth = 0.05;
124
+ ctx.beginPath();
125
+ ctx.moveTo(-0.08, -0.08);
126
+ ctx.lineTo(0.08, -0.08);
127
+ ctx.stroke();
128
+ });
129
+ }
130
+
131
+ function drawCat(ctx: CanvasRenderingContext2D, face: FaceGeometry): void {
132
+ inFaceFrame(ctx, face.forehead, face, () => {
133
+ bothSides(ctx, () => {
134
+ ctx.fillStyle = "#3b3b44";
135
+ ctx.beginPath();
136
+ ctx.moveTo(0.68, 0.14);
137
+ ctx.lineTo(0.12, 0);
138
+ ctx.lineTo(0.62, -0.55);
139
+ ctx.closePath();
140
+ ctx.fill();
141
+ ctx.fillStyle = "#f4a6b8";
142
+ ctx.beginPath();
143
+ ctx.moveTo(0.56, 0.06);
144
+ ctx.lineTo(0.22, 0);
145
+ ctx.lineTo(0.58, -0.33);
146
+ ctx.closePath();
147
+ ctx.fill();
148
+ });
149
+ });
150
+ inFaceFrame(ctx, face.noseTip, face, () => {
151
+ ctx.fillStyle = "#f06a8a";
152
+ ctx.beginPath();
153
+ ctx.moveTo(-0.09, -0.04);
154
+ ctx.lineTo(0.09, -0.04);
155
+ ctx.lineTo(0, 0.06);
156
+ ctx.closePath();
157
+ ctx.fill();
158
+ ctx.lineCap = "round";
159
+ bothSides(ctx, () => {
160
+ for (const tilt of [-1, 0, 1]) {
161
+ ctx.beginPath();
162
+ ctx.moveTo(0.3, 0.04 + tilt * 0.07);
163
+ ctx.lineTo(0.85, -0.02 + tilt * 0.15);
164
+ // A dark line under a white one, so the whiskers read against a light or a dark background.
165
+ ctx.strokeStyle = "rgba(0, 0, 0, 0.45)";
166
+ ctx.lineWidth = 0.035;
167
+ ctx.stroke();
168
+ ctx.strokeStyle = "#ffffff";
169
+ ctx.lineWidth = 0.018;
170
+ ctx.stroke();
171
+ }
172
+ });
173
+ });
174
+ }
175
+
176
+ function drawPartyHat(ctx: CanvasRenderingContext2D, face: FaceGeometry): void {
177
+ inFaceFrame(ctx, face.forehead, face, () => {
178
+ ctx.save();
179
+ ctx.beginPath();
180
+ ctx.moveTo(-0.42, 0.1);
181
+ ctx.lineTo(0.42, 0.1);
182
+ ctx.lineTo(0.04, -1.15);
183
+ ctx.closePath();
184
+ ctx.clip();
185
+ ctx.fillStyle = "#ff5fa2";
186
+ ctx.fillRect(-0.6, -1.3, 1.2, 1.5);
187
+ ctx.strokeStyle = "#ffd23f";
188
+ ctx.lineWidth = 0.09;
189
+ for (const y of [-0.12, -0.42, -0.72]) {
190
+ ctx.beginPath();
191
+ ctx.moveTo(-0.6, y + 0.2);
192
+ ctx.lineTo(0.6, y - 0.2);
193
+ ctx.stroke();
194
+ }
195
+ ctx.restore();
196
+ ctx.fillStyle = "#ffffff";
197
+ ctx.beginPath();
198
+ ctx.arc(0.04, -1.15, 0.09, 0, Math.PI * 2);
199
+ ctx.fill();
200
+ });
201
+ }
202
+
203
+ function drawCrown(ctx: CanvasRenderingContext2D, face: FaceGeometry): void {
204
+ inFaceFrame(ctx, face.forehead, face, () => {
205
+ ctx.beginPath();
206
+ ctx.moveTo(-0.5, 0.1);
207
+ ctx.lineTo(-0.5, -0.45);
208
+ ctx.lineTo(-0.25, -0.2);
209
+ ctx.lineTo(0, -0.55);
210
+ ctx.lineTo(0.25, -0.2);
211
+ ctx.lineTo(0.5, -0.45);
212
+ ctx.lineTo(0.5, 0.1);
213
+ ctx.closePath();
214
+ ctx.fillStyle = "#f5c518";
215
+ ctx.fill();
216
+ ctx.strokeStyle = "#b8860b";
217
+ ctx.lineWidth = 0.03;
218
+ ctx.lineJoin = "round";
219
+ ctx.stroke();
220
+ for (const [x, y, color] of [
221
+ [-0.5, -0.45, "#e63946"],
222
+ [0, -0.55, "#3a86ff"],
223
+ [0.5, -0.45, "#e63946"],
224
+ [0, -0.05, "#2a9d8f"],
225
+ ] as const) {
226
+ ctx.fillStyle = color;
227
+ ctx.beginPath();
228
+ ctx.arc(x, y, 0.05, 0, Math.PI * 2);
229
+ ctx.fill();
230
+ }
231
+ });
232
+ }
233
+
234
+ function drawMustache(ctx: CanvasRenderingContext2D, face: FaceGeometry): void {
235
+ const between: Point = { x: (face.noseTip.x + face.upperLip.x) / 2, y: (face.noseTip.y + face.upperLip.y) / 2 };
236
+ inFaceFrame(ctx, between, face, () => {
237
+ ctx.fillStyle = "#3a2618";
238
+ ctx.beginPath();
239
+ ctx.moveTo(0, -0.03);
240
+ ctx.bezierCurveTo(-0.12, -0.12, -0.32, -0.1, -0.42, 0.02);
241
+ ctx.bezierCurveTo(-0.46, 0.07, -0.4, 0.13, -0.34, 0.09);
242
+ ctx.bezierCurveTo(-0.26, 0.04, -0.12, 0.06, 0, 0.09);
243
+ ctx.bezierCurveTo(0.12, 0.06, 0.26, 0.04, 0.34, 0.09);
244
+ ctx.bezierCurveTo(0.4, 0.13, 0.46, 0.07, 0.42, 0.02);
245
+ ctx.bezierCurveTo(0.32, -0.1, 0.12, -0.12, 0, -0.03);
246
+ ctx.closePath();
247
+ ctx.fill();
248
+ });
249
+ }
250
+
251
+ const DRAWERS: Record<Exclude<Accessory, "none">, (ctx: CanvasRenderingContext2D, face: FaceGeometry) => void> = {
252
+ sunglasses: drawSunglasses,
253
+ "cat-ears": drawCat,
254
+ "party-hat": drawPartyHat,
255
+ crown: drawCrown,
256
+ mustache: drawMustache,
257
+ };
258
+
259
+ /** Draws `accessory` onto `ctx` for the face at `face`. */
260
+ export function drawAccessory(ctx: CanvasRenderingContext2D, accessory: Exclude<Accessory, "none">, face: FaceGeometry): void {
261
+ DRAWERS[accessory](ctx, face);
262
+ }