@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,304 @@
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 { drawAccessory, faceGeometry } from "./accessories.js";
26
+ import { needsFace, needsSegmentation } from "./filterTypes.js";
27
+ import { createFaceTracker, createPersonSegmenter } from "./mlModels.js";
28
+ import { applyGrayscale, applyNightVision, applySepia, maskToAlpha } from "./pixelEffects.js";
29
+ const DEFAULT_FRAME_RATE = 30;
30
+ /** The background is blurred by drawing the frame at 1/this size and scaling it back up. */
31
+ const BLUR_DIVISOR = 12;
32
+ /** The picture is pixelated into about this many blocks across. */
33
+ const PIXEL_BLOCKS = 80;
34
+ /** How many frames in a row without a detected face before the accessory is taken off - a face is briefly lost when
35
+ * the head turns, and the accessory shouldn't flicker for that. */
36
+ const FACE_HOLD_FRAMES = 6;
37
+ const SEGMENTATION_ERROR = "The background effect couldn't be loaded, so your background stays hidden. Turn the effect off to show your camera.";
38
+ const FACE_ERROR = "The face effect couldn't be loaded.";
39
+ const RENDER_ERROR = "Video effects stopped working.";
40
+ const PIXEL_EFFECTS = { bw: applyGrayscale, sepia: applySepia, "night-vision": applyNightVision };
41
+ const DEFAULT_DEPS = {
42
+ createCanvas: () => document.createElement("canvas"),
43
+ createVideo: () => document.createElement("video"),
44
+ createSegmenter: createPersonSegmenter,
45
+ createFaceTracker,
46
+ now: () => performance.now(),
47
+ };
48
+ function context2d(canvas) {
49
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
50
+ if (!ctx) {
51
+ throw new Error("This browser can't draw video effects.");
52
+ }
53
+ return ctx;
54
+ }
55
+ export class VideoFilterProcessor {
56
+ constructor(options) {
57
+ this.layers = new Map();
58
+ this.stopped = false;
59
+ this.segmenter = null;
60
+ this.segmenterState = "idle";
61
+ this.faceTracker = null;
62
+ this.faceState = "idle";
63
+ this.lastFace = null;
64
+ this.framesWithoutFace = 0;
65
+ this.maskImage = null;
66
+ this.renderFailed = false;
67
+ this.status = { loading: false, error: null };
68
+ this.tick = () => {
69
+ if (this.stopped) {
70
+ return;
71
+ }
72
+ const started = this.deps.now();
73
+ if (this.video.readyState >= 2 && this.video.videoWidth > 0) {
74
+ try {
75
+ this.render(started);
76
+ if (this.renderFailed) {
77
+ // Drawing works again - a single bad frame doesn't leave the warning up.
78
+ this.renderFailed = false;
79
+ this.publishStatus();
80
+ }
81
+ }
82
+ catch {
83
+ // A frame that can't be drawn is skipped; the loop carries on, and the participant is told.
84
+ if (!this.renderFailed) {
85
+ this.renderFailed = true;
86
+ this.publishStatus();
87
+ }
88
+ }
89
+ }
90
+ this.timer = setTimeout(this.tick, Math.max(0, this.interval - (this.deps.now() - started)));
91
+ };
92
+ this.deps = { ...DEFAULT_DEPS, ...options.deps };
93
+ this.filters = options.filters;
94
+ this.backgroundImage = options.backgroundImage ?? null;
95
+ this.assetsUrl = options.assetsUrl;
96
+ this.onStatus = options.onStatus;
97
+ const frameRate = options.frameRate ?? DEFAULT_FRAME_RATE;
98
+ this.interval = 1000 / frameRate;
99
+ this.canvas = this.deps.createCanvas();
100
+ this.ctx = context2d(this.canvas);
101
+ this.track = this.canvas.captureStream(frameRate).getVideoTracks()[0];
102
+ this.video = this.deps.createVideo();
103
+ this.video.muted = true;
104
+ this.video.playsInline = true;
105
+ this.video.srcObject = new MediaStream([options.source]);
106
+ void this.video.play().catch(() => undefined);
107
+ this.syncModels();
108
+ this.publishStatus();
109
+ this.tick();
110
+ }
111
+ /** Changes the filters (and the background picture) without interrupting the video. Also retries a model that failed
112
+ * to load, so choosing a filter again is a retry. */
113
+ update(filters, backgroundImage) {
114
+ this.filters = filters;
115
+ this.backgroundImage = backgroundImage;
116
+ this.syncModels(true);
117
+ this.publishStatus();
118
+ }
119
+ stop() {
120
+ this.stopped = true;
121
+ clearTimeout(this.timer);
122
+ this.video.pause();
123
+ this.video.srcObject = null;
124
+ this.track.stop();
125
+ }
126
+ syncModels(retry = false) {
127
+ if (needsSegmentation(this.filters) && (this.segmenterState === "idle" || (retry && this.segmenterState === "failed"))) {
128
+ this.segmenterState = "loading";
129
+ this.deps.createSegmenter(this.assetsUrl).then((segmenter) => {
130
+ this.segmenter = segmenter;
131
+ this.segmenterState = "ready";
132
+ this.publishStatus();
133
+ }, () => {
134
+ this.segmenterState = "failed";
135
+ this.publishStatus();
136
+ });
137
+ }
138
+ if (needsFace(this.filters) && (this.faceState === "idle" || (retry && this.faceState === "failed"))) {
139
+ this.faceState = "loading";
140
+ this.deps.createFaceTracker(this.assetsUrl).then((tracker) => {
141
+ this.faceTracker = tracker;
142
+ this.faceState = "ready";
143
+ this.publishStatus();
144
+ }, () => {
145
+ this.faceState = "failed";
146
+ this.publishStatus();
147
+ });
148
+ }
149
+ }
150
+ publishStatus() {
151
+ if (this.stopped) {
152
+ return;
153
+ }
154
+ const wantsSegmentation = needsSegmentation(this.filters);
155
+ const wantsFace = needsFace(this.filters);
156
+ let error = null;
157
+ if (wantsSegmentation && this.segmenterState === "failed") {
158
+ error = SEGMENTATION_ERROR;
159
+ }
160
+ else if (wantsFace && this.faceState === "failed") {
161
+ error = FACE_ERROR;
162
+ }
163
+ else if (this.renderFailed) {
164
+ error = RENDER_ERROR;
165
+ }
166
+ const loading = (wantsSegmentation && this.segmenterState === "loading") || (wantsFace && this.faceState === "loading");
167
+ if (loading !== this.status.loading || error !== this.status.error) {
168
+ this.status = { loading, error };
169
+ this.onStatus?.(this.status);
170
+ }
171
+ }
172
+ /** A scratch canvas of the given size, kept between frames. */
173
+ layer(name, width, height) {
174
+ let layer = this.layers.get(name);
175
+ if (!layer) {
176
+ const canvas = this.deps.createCanvas();
177
+ layer = { canvas, ctx: context2d(canvas) };
178
+ this.layers.set(name, layer);
179
+ }
180
+ if (layer.canvas.width !== width || layer.canvas.height !== height) {
181
+ layer.canvas.width = width;
182
+ layer.canvas.height = height;
183
+ }
184
+ return layer;
185
+ }
186
+ render(now) {
187
+ const { video, canvas, ctx, filters } = this;
188
+ const width = video.videoWidth;
189
+ const height = video.videoHeight;
190
+ if (canvas.width !== width || canvas.height !== height) {
191
+ canvas.width = width;
192
+ canvas.height = height;
193
+ }
194
+ if (filters.background === "none") {
195
+ ctx.drawImage(video, 0, 0, width, height);
196
+ }
197
+ else {
198
+ this.drawBackground(now, width, height);
199
+ }
200
+ this.applyEffect(width, height);
201
+ if (filters.accessory !== "none") {
202
+ this.paintAccessory(filters.accessory, now, width, height);
203
+ }
204
+ }
205
+ /** The background, then - once the segmentation model is ready - the participant on top of it. */
206
+ drawBackground(now, width, height) {
207
+ const { video, ctx } = this;
208
+ const picture = this.backgroundImage;
209
+ if (this.filters.background === "image" && picture) {
210
+ const scale = Math.max(width / picture.width, height / picture.height);
211
+ const drawnWidth = picture.width * scale;
212
+ const drawnHeight = picture.height * scale;
213
+ ctx.drawImage(picture.source, (width - drawnWidth) / 2, (height - drawnHeight) / 2, drawnWidth, drawnHeight);
214
+ }
215
+ else {
216
+ // Also the stand-in for a "custom image" background that has no image: blurred, never the real room.
217
+ const small = this.layer("blur", Math.ceil(width / BLUR_DIVISOR), Math.ceil(height / BLUR_DIVISOR));
218
+ small.ctx.imageSmoothingQuality = "high";
219
+ small.ctx.drawImage(video, 0, 0, small.canvas.width, small.canvas.height);
220
+ ctx.imageSmoothingQuality = "high";
221
+ ctx.drawImage(small.canvas, 0, 0, width, height);
222
+ }
223
+ const mask = this.segment(now);
224
+ if (!mask) {
225
+ return;
226
+ }
227
+ const maskLayer = this.layer("mask", mask.width, mask.height);
228
+ let image = this.maskImage;
229
+ if (!image || image.width !== mask.width || image.height !== mask.height) {
230
+ image = this.maskImage = maskLayer.ctx.createImageData(mask.width, mask.height);
231
+ }
232
+ maskToAlpha(mask.confidence, image.data);
233
+ maskLayer.ctx.putImageData(image, 0, 0);
234
+ const person = this.layer("person", width, height);
235
+ person.ctx.globalCompositeOperation = "source-over";
236
+ person.ctx.clearRect(0, 0, width, height);
237
+ person.ctx.drawImage(maskLayer.canvas, 0, 0, width, height);
238
+ person.ctx.globalCompositeOperation = "source-in";
239
+ person.ctx.drawImage(video, 0, 0, width, height);
240
+ person.ctx.globalCompositeOperation = "source-over";
241
+ ctx.drawImage(person.canvas, 0, 0);
242
+ }
243
+ segment(now) {
244
+ if (!this.segmenter) {
245
+ return null;
246
+ }
247
+ try {
248
+ return this.segmenter.segment(this.video, now);
249
+ }
250
+ catch {
251
+ this.segmenter = null;
252
+ this.segmenterState = "failed";
253
+ this.publishStatus();
254
+ return null;
255
+ }
256
+ }
257
+ applyEffect(width, height) {
258
+ const { ctx } = this;
259
+ const effect = this.filters.effect;
260
+ if (effect === "pixelate") {
261
+ const small = this.layer("pixel", PIXEL_BLOCKS, Math.ceil((height / width) * PIXEL_BLOCKS));
262
+ small.ctx.drawImage(this.canvas, 0, 0, small.canvas.width, small.canvas.height);
263
+ ctx.imageSmoothingEnabled = false;
264
+ ctx.drawImage(small.canvas, 0, 0, width, height);
265
+ ctx.imageSmoothingEnabled = true;
266
+ }
267
+ else if (effect !== "none") {
268
+ const pixels = ctx.getImageData(0, 0, width, height);
269
+ PIXEL_EFFECTS[effect](pixels.data);
270
+ ctx.putImageData(pixels, 0, 0);
271
+ }
272
+ }
273
+ paintAccessory(accessory, now, width, height) {
274
+ const face = this.trackFace(now);
275
+ const geometry = face ? faceGeometry(face, width, height) : null;
276
+ if (geometry) {
277
+ drawAccessory(this.ctx, accessory, geometry);
278
+ }
279
+ }
280
+ /** The face's landmarks for this frame - the last known ones for a few frames after it is lost. */
281
+ trackFace(now) {
282
+ if (!this.faceTracker) {
283
+ return null;
284
+ }
285
+ let found = null;
286
+ try {
287
+ found = this.faceTracker.detect(this.video, now);
288
+ }
289
+ catch {
290
+ this.faceTracker = null;
291
+ this.faceState = "failed";
292
+ this.publishStatus();
293
+ return null;
294
+ }
295
+ if (found) {
296
+ this.lastFace = found;
297
+ this.framesWithoutFace = 0;
298
+ }
299
+ else if (++this.framesWithoutFace > FACE_HOLD_FRAMES) {
300
+ this.lastFace = null;
301
+ }
302
+ return this.lastFace;
303
+ }
304
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The face accessories - sunglasses, cat ears, a party hat, a crown, a mustache - drawn as vector paths onto the
3
+ * filtered frame, so the plugin ships no image assets at all.
4
+ *
5
+ * Each one is drawn in the face's own coordinate frame: the origin sits on a landmark (between the eyes, the top of
6
+ * the forehead, the nose), the x axis runs along the line between the eyes so the accessory tilts with the head, and
7
+ * one unit is the distance between the outer corners of the eyes so it grows and shrinks with the head. That keeps
8
+ * every drawing routine below a few plain numbers in "face widths".
9
+ */
10
+ import type { Accessory } from "./filterTypes.js";
11
+ /** A landmark as the face model reports it: 0-1 across and down the frame. */
12
+ export interface FaceLandmark {
13
+ x: number;
14
+ y: number;
15
+ }
16
+ export interface Point {
17
+ x: number;
18
+ y: number;
19
+ }
20
+ /** Where the parts of a face are, in canvas pixels. */
21
+ export interface FaceGeometry {
22
+ /** Halfway between the outer corners of the eyes. */
23
+ eyes: Point;
24
+ /** The top of the forehead. */
25
+ forehead: Point;
26
+ noseTip: Point;
27
+ upperLip: Point;
28
+ /** The distance between the outer corners of the eyes - one unit of the accessories' coordinate frame. */
29
+ unit: number;
30
+ /** The tilt of the line between the eyes, in radians. */
31
+ angle: number;
32
+ }
33
+ /** Works out where the parts of a face are, in pixels, from the face mesh's landmarks - `null` when there are not
34
+ * enough of them to be a face mesh at all. */
35
+ export declare function faceGeometry(landmarks: FaceLandmark[], width: number, height: number): FaceGeometry | null;
36
+ /** Draws `accessory` onto `ctx` for the face at `face`. */
37
+ export declare function drawAccessory(ctx: CanvasRenderingContext2D, accessory: Exclude<Accessory, "none">, face: FaceGeometry): void;
@@ -0,0 +1,211 @@
1
+ // Indices into the 478-point face mesh (MediaPipe's canonical face model).
2
+ const RIGHT_EYE_OUTER = 33;
3
+ const LEFT_EYE_OUTER = 263;
4
+ const FOREHEAD_TOP = 10;
5
+ const NOSE_TIP = 1;
6
+ const UPPER_LIP = 0;
7
+ const MESH_POINTS = 468;
8
+ /** Works out where the parts of a face are, in pixels, from the face mesh's landmarks - `null` when there are not
9
+ * enough of them to be a face mesh at all. */
10
+ export function faceGeometry(landmarks, width, height) {
11
+ if (landmarks.length < MESH_POINTS) {
12
+ return null;
13
+ }
14
+ const at = (index) => ({ x: landmarks[index].x * width, y: landmarks[index].y * height });
15
+ const right = at(RIGHT_EYE_OUTER);
16
+ const left = at(LEFT_EYE_OUTER);
17
+ return {
18
+ eyes: { x: (right.x + left.x) / 2, y: (right.y + left.y) / 2 },
19
+ forehead: at(FOREHEAD_TOP),
20
+ noseTip: at(NOSE_TIP),
21
+ upperLip: at(UPPER_LIP),
22
+ unit: Math.hypot(left.x - right.x, left.y - right.y),
23
+ angle: Math.atan2(left.y - right.y, left.x - right.x),
24
+ };
25
+ }
26
+ /** Runs `draw` with the canvas moved to `origin`, turned by the face's tilt and scaled to one face width per unit. */
27
+ function inFaceFrame(ctx, origin, face, draw) {
28
+ ctx.save();
29
+ ctx.translate(origin.x, origin.y);
30
+ ctx.rotate(face.angle);
31
+ ctx.scale(face.unit, face.unit);
32
+ draw();
33
+ ctx.restore();
34
+ }
35
+ /** Runs `draw` once as it is and once flipped left-right, for the two matching halves of a symmetrical accessory. */
36
+ function bothSides(ctx, draw) {
37
+ for (const side of [1, -1]) {
38
+ ctx.save();
39
+ ctx.scale(side, 1);
40
+ draw();
41
+ ctx.restore();
42
+ }
43
+ }
44
+ function roundedRect(ctx, x, y, w, h, r) {
45
+ ctx.beginPath();
46
+ ctx.moveTo(x + r, y);
47
+ ctx.arcTo(x + w, y, x + w, y + h, r);
48
+ ctx.arcTo(x + w, y + h, x, y + h, r);
49
+ ctx.arcTo(x, y + h, x, y, r);
50
+ ctx.arcTo(x, y, x + w, y, r);
51
+ ctx.closePath();
52
+ }
53
+ function drawSunglasses(ctx, face) {
54
+ inFaceFrame(ctx, face.eyes, face, () => {
55
+ ctx.fillStyle = "rgba(12, 12, 18, 0.93)";
56
+ ctx.strokeStyle = "rgba(12, 12, 18, 0.93)";
57
+ ctx.lineCap = "round";
58
+ bothSides(ctx, () => {
59
+ roundedRect(ctx, 0.06, -0.17, 0.54, 0.38, 0.12);
60
+ ctx.fill();
61
+ // The arm that runs back over the ear.
62
+ ctx.lineWidth = 0.04;
63
+ ctx.beginPath();
64
+ ctx.moveTo(0.6, -0.06);
65
+ ctx.lineTo(0.8, -0.1);
66
+ ctx.stroke();
67
+ // A glint across the lens.
68
+ ctx.fillStyle = "rgba(255, 255, 255, 0.2)";
69
+ ctx.beginPath();
70
+ ctx.moveTo(0.14, -0.1);
71
+ ctx.lineTo(0.28, -0.1);
72
+ ctx.lineTo(0.16, 0.12);
73
+ ctx.lineTo(0.1, 0.12);
74
+ ctx.closePath();
75
+ ctx.fill();
76
+ });
77
+ // The bridge between the lenses.
78
+ ctx.lineWidth = 0.05;
79
+ ctx.beginPath();
80
+ ctx.moveTo(-0.08, -0.08);
81
+ ctx.lineTo(0.08, -0.08);
82
+ ctx.stroke();
83
+ });
84
+ }
85
+ function drawCat(ctx, face) {
86
+ inFaceFrame(ctx, face.forehead, face, () => {
87
+ bothSides(ctx, () => {
88
+ ctx.fillStyle = "#3b3b44";
89
+ ctx.beginPath();
90
+ ctx.moveTo(0.68, 0.14);
91
+ ctx.lineTo(0.12, 0);
92
+ ctx.lineTo(0.62, -0.55);
93
+ ctx.closePath();
94
+ ctx.fill();
95
+ ctx.fillStyle = "#f4a6b8";
96
+ ctx.beginPath();
97
+ ctx.moveTo(0.56, 0.06);
98
+ ctx.lineTo(0.22, 0);
99
+ ctx.lineTo(0.58, -0.33);
100
+ ctx.closePath();
101
+ ctx.fill();
102
+ });
103
+ });
104
+ inFaceFrame(ctx, face.noseTip, face, () => {
105
+ ctx.fillStyle = "#f06a8a";
106
+ ctx.beginPath();
107
+ ctx.moveTo(-0.09, -0.04);
108
+ ctx.lineTo(0.09, -0.04);
109
+ ctx.lineTo(0, 0.06);
110
+ ctx.closePath();
111
+ ctx.fill();
112
+ ctx.lineCap = "round";
113
+ bothSides(ctx, () => {
114
+ for (const tilt of [-1, 0, 1]) {
115
+ ctx.beginPath();
116
+ ctx.moveTo(0.3, 0.04 + tilt * 0.07);
117
+ ctx.lineTo(0.85, -0.02 + tilt * 0.15);
118
+ // A dark line under a white one, so the whiskers read against a light or a dark background.
119
+ ctx.strokeStyle = "rgba(0, 0, 0, 0.45)";
120
+ ctx.lineWidth = 0.035;
121
+ ctx.stroke();
122
+ ctx.strokeStyle = "#ffffff";
123
+ ctx.lineWidth = 0.018;
124
+ ctx.stroke();
125
+ }
126
+ });
127
+ });
128
+ }
129
+ function drawPartyHat(ctx, face) {
130
+ inFaceFrame(ctx, face.forehead, face, () => {
131
+ ctx.save();
132
+ ctx.beginPath();
133
+ ctx.moveTo(-0.42, 0.1);
134
+ ctx.lineTo(0.42, 0.1);
135
+ ctx.lineTo(0.04, -1.15);
136
+ ctx.closePath();
137
+ ctx.clip();
138
+ ctx.fillStyle = "#ff5fa2";
139
+ ctx.fillRect(-0.6, -1.3, 1.2, 1.5);
140
+ ctx.strokeStyle = "#ffd23f";
141
+ ctx.lineWidth = 0.09;
142
+ for (const y of [-0.12, -0.42, -0.72]) {
143
+ ctx.beginPath();
144
+ ctx.moveTo(-0.6, y + 0.2);
145
+ ctx.lineTo(0.6, y - 0.2);
146
+ ctx.stroke();
147
+ }
148
+ ctx.restore();
149
+ ctx.fillStyle = "#ffffff";
150
+ ctx.beginPath();
151
+ ctx.arc(0.04, -1.15, 0.09, 0, Math.PI * 2);
152
+ ctx.fill();
153
+ });
154
+ }
155
+ function drawCrown(ctx, face) {
156
+ inFaceFrame(ctx, face.forehead, face, () => {
157
+ ctx.beginPath();
158
+ ctx.moveTo(-0.5, 0.1);
159
+ ctx.lineTo(-0.5, -0.45);
160
+ ctx.lineTo(-0.25, -0.2);
161
+ ctx.lineTo(0, -0.55);
162
+ ctx.lineTo(0.25, -0.2);
163
+ ctx.lineTo(0.5, -0.45);
164
+ ctx.lineTo(0.5, 0.1);
165
+ ctx.closePath();
166
+ ctx.fillStyle = "#f5c518";
167
+ ctx.fill();
168
+ ctx.strokeStyle = "#b8860b";
169
+ ctx.lineWidth = 0.03;
170
+ ctx.lineJoin = "round";
171
+ ctx.stroke();
172
+ for (const [x, y, color] of [
173
+ [-0.5, -0.45, "#e63946"],
174
+ [0, -0.55, "#3a86ff"],
175
+ [0.5, -0.45, "#e63946"],
176
+ [0, -0.05, "#2a9d8f"],
177
+ ]) {
178
+ ctx.fillStyle = color;
179
+ ctx.beginPath();
180
+ ctx.arc(x, y, 0.05, 0, Math.PI * 2);
181
+ ctx.fill();
182
+ }
183
+ });
184
+ }
185
+ function drawMustache(ctx, face) {
186
+ const between = { x: (face.noseTip.x + face.upperLip.x) / 2, y: (face.noseTip.y + face.upperLip.y) / 2 };
187
+ inFaceFrame(ctx, between, face, () => {
188
+ ctx.fillStyle = "#3a2618";
189
+ ctx.beginPath();
190
+ ctx.moveTo(0, -0.03);
191
+ ctx.bezierCurveTo(-0.12, -0.12, -0.32, -0.1, -0.42, 0.02);
192
+ ctx.bezierCurveTo(-0.46, 0.07, -0.4, 0.13, -0.34, 0.09);
193
+ ctx.bezierCurveTo(-0.26, 0.04, -0.12, 0.06, 0, 0.09);
194
+ ctx.bezierCurveTo(0.12, 0.06, 0.26, 0.04, 0.34, 0.09);
195
+ ctx.bezierCurveTo(0.4, 0.13, 0.46, 0.07, 0.42, 0.02);
196
+ ctx.bezierCurveTo(0.32, -0.1, 0.12, -0.12, 0, -0.03);
197
+ ctx.closePath();
198
+ ctx.fill();
199
+ });
200
+ }
201
+ const DRAWERS = {
202
+ sunglasses: drawSunglasses,
203
+ "cat-ears": drawCat,
204
+ "party-hat": drawPartyHat,
205
+ crown: drawCrown,
206
+ mustache: drawMustache,
207
+ };
208
+ /** Draws `accessory` onto `ctx` for the face at `face`. */
209
+ export function drawAccessory(ctx, accessory, face) {
210
+ DRAWERS[accessory](ctx, face);
211
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The picture behind a participant for the "custom image" background: read from a file they pick on their own
3
+ * device, shrunk to what a video call can show anyway, and kept as a small JPEG data URL so it can be remembered
4
+ * (`mediaPreferences.ts`) without filling the browser's storage. The file never leaves the browser.
5
+ */
6
+ import type { BackgroundPicture } from "./VideoFilterProcessor.js";
7
+ /** A bigger file than this is refused before the browser tries to decode it. */
8
+ export declare const MAX_BACKGROUND_FILE_BYTES: number;
9
+ /** The longest edge kept - a call's video is 1280 wide, so more would only cost storage. */
10
+ export declare const MAX_BACKGROUND_EDGE = 1280;
11
+ export type PreparedBackground = {
12
+ ok: true;
13
+ dataUrl: string;
14
+ picture: BackgroundPicture;
15
+ } | {
16
+ ok: false;
17
+ message: string;
18
+ };
19
+ /** Decodes an image (a URL or data URL) into something that can be drawn. Rejects if it isn't a picture. */
20
+ export declare function loadPicture(src: string): Promise<BackgroundPicture>;
21
+ /** Reads a picked file into a background: validated, downscaled and ready both to draw and to store. */
22
+ export declare function prepareBackgroundImage(file: File): Promise<PreparedBackground>;
@@ -0,0 +1,54 @@
1
+ /** A bigger file than this is refused before the browser tries to decode it. */
2
+ export const MAX_BACKGROUND_FILE_BYTES = 20 * 1024 * 1024;
3
+ /** The longest edge kept - a call's video is 1280 wide, so more would only cost storage. */
4
+ export const MAX_BACKGROUND_EDGE = 1280;
5
+ const JPEG_QUALITY = 0.85;
6
+ /** Decodes an image (a URL or data URL) into something that can be drawn. Rejects if it isn't a picture. */
7
+ export function loadPicture(src) {
8
+ return new Promise((resolve, reject) => {
9
+ const image = new Image();
10
+ image.onload = () => resolve({ source: image, width: image.naturalWidth, height: image.naturalHeight });
11
+ image.onerror = () => reject(new Error("The image could not be read."));
12
+ image.src = src;
13
+ });
14
+ }
15
+ function readAsDataUrl(file) {
16
+ return new Promise((resolve, reject) => {
17
+ const reader = new FileReader();
18
+ reader.onload = () => resolve(String(reader.result));
19
+ reader.onerror = () => reject(new Error("The file could not be read."));
20
+ reader.readAsDataURL(file);
21
+ });
22
+ }
23
+ /** Reads a picked file into a background: validated, downscaled and ready both to draw and to store. */
24
+ export async function prepareBackgroundImage(file) {
25
+ if (!file.type.startsWith("image/")) {
26
+ return { ok: false, message: "Choose an image file (a JPEG, PNG or similar)." };
27
+ }
28
+ if (file.size > MAX_BACKGROUND_FILE_BYTES) {
29
+ return { ok: false, message: "That image is too large. Choose one under 20 MB." };
30
+ }
31
+ try {
32
+ const original = await loadPicture(await readAsDataUrl(file));
33
+ const scale = Math.min(1, MAX_BACKGROUND_EDGE / Math.max(original.width, original.height));
34
+ const canvas = document.createElement("canvas");
35
+ canvas.width = Math.max(1, Math.round(original.width * scale));
36
+ canvas.height = Math.max(1, Math.round(original.height * scale));
37
+ const ctx = canvas.getContext("2d");
38
+ if (!ctx) {
39
+ return { ok: false, message: "This browser can't prepare that image." };
40
+ }
41
+ // JPEG has no transparency: a transparent PNG would otherwise turn black.
42
+ ctx.fillStyle = "#ffffff";
43
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
44
+ ctx.drawImage(original.source, 0, 0, canvas.width, canvas.height);
45
+ return {
46
+ ok: true,
47
+ dataUrl: canvas.toDataURL("image/jpeg", JPEG_QUALITY),
48
+ picture: { source: canvas, width: canvas.width, height: canvas.height },
49
+ };
50
+ }
51
+ catch {
52
+ return { ok: false, message: "That file couldn't be opened as an image." };
53
+ }
54
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The video filters a participant can put on their camera. They come in three independent layers, so they combine
3
+ * (blurred background *and* black and white *and* sunglasses):
4
+ *
5
+ * - **Background**: what is behind the participant - untouched, blurred, or a picture they chose. Needs person
6
+ * segmentation (`mlModels.ts`).
7
+ * - **Effect**: a look applied to the whole picture - black and white, sepia, night vision, pixelated.
8
+ * - **Accessory**: something drawn on the participant's face - sunglasses, cat ears, a party hat. Needs face landmarks
9
+ * (`mlModels.ts`).
10
+ *
11
+ * Adding a filter is: add its id here (with a label), then implement it in `VideoFilterProcessor.ts` (an effect), or
12
+ * in `accessories.ts` (an accessory).
13
+ */
14
+ export type BackgroundFilter = "none" | "blur" | "image";
15
+ export type ColorEffect = "none" | "bw" | "sepia" | "night-vision" | "pixelate";
16
+ export type Accessory = "none" | "sunglasses" | "cat-ears" | "party-hat" | "crown" | "mustache";
17
+ export interface VideoFilters {
18
+ background: BackgroundFilter;
19
+ effect: ColorEffect;
20
+ accessory: Accessory;
21
+ }
22
+ export declare const NO_FILTERS: VideoFilters;
23
+ export interface FilterOption<T extends string> {
24
+ id: T;
25
+ label: string;
26
+ /** A glyph shown beside the label, purely decorative. */
27
+ emoji: string;
28
+ }
29
+ export declare const BACKGROUND_OPTIONS: FilterOption<BackgroundFilter>[];
30
+ export declare const EFFECT_OPTIONS: FilterOption<ColorEffect>[];
31
+ export declare const ACCESSORY_OPTIONS: FilterOption<Accessory>[];
32
+ /** Whether any filter is on - when none is, the camera's own track is sent untouched and nothing is processed. */
33
+ export declare function filtersActive(filters: VideoFilters): boolean;
34
+ /** Whether the filters need the person-segmentation model. */
35
+ export declare function needsSegmentation(filters: VideoFilters): boolean;
36
+ /** Whether the filters need the face-landmark model. */
37
+ export declare function needsFace(filters: VideoFilters): boolean;
38
+ /** Turns whatever was read back from storage into valid filters - anything unrecognised (a filter since removed, a
39
+ * hand-edited value, another version's shape) falls back to "none" rather than breaking the call. */
40
+ export declare function sanitizeFilters(value: unknown): VideoFilters;