@rapidmx/meet-plugin 0.6.0 → 0.8.1

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 (131) hide show
  1. package/README.md +18 -5
  2. package/apps/meet/[token].tsx +11 -10
  3. package/apps/meet/_CallControls.tsx +28 -2
  4. package/apps/meet/_CallView.tsx +26 -2
  5. package/apps/meet/_EffectsPanel.tsx +134 -0
  6. package/apps/meet/_MeetChrome.tsx +1 -1
  7. package/apps/meet/_MeetLobby.tsx +36 -4
  8. package/apps/meet/_ParticipantTile.tsx +73 -10
  9. package/apps/meet/_icons.tsx +4 -0
  10. package/apps/meet/_layout.tsx +2 -2
  11. package/apps/meet/_meetApi.ts +7 -1
  12. package/apps/meet/index.tsx +2 -2
  13. package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +4 -4
  14. package/apps/settings-video-conferencing/_layout.tsx +2 -2
  15. package/apps/settings-video-conferencing/index.tsx +3 -3
  16. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -0
  17. package/apps/shared/media/filters/accessories.ts +262 -0
  18. package/apps/shared/media/filters/backgroundImage.ts +69 -0
  19. package/apps/shared/media/filters/filterTypes.ts +89 -0
  20. package/apps/shared/media/filters/mlModels.ts +174 -0
  21. package/apps/shared/media/filters/pixelEffects.ts +61 -0
  22. package/apps/shared/media/mediaPreferences.ts +105 -0
  23. package/apps/shared/media/useLocalMedia.ts +198 -14
  24. package/apps/shared/push/GuestSignalingClient.ts +4 -4
  25. package/apps/shared/relay/AudioPlayer.ts +111 -0
  26. package/apps/shared/relay/AudioSender.ts +194 -0
  27. package/apps/shared/relay/RelayClient.ts +262 -0
  28. package/apps/shared/relay/RelayReceiver.ts +72 -0
  29. package/apps/shared/relay/RelaySender.ts +74 -0
  30. package/apps/shared/relay/RelayTransport.ts +188 -0
  31. package/apps/shared/relay/VideoPlayer.ts +110 -0
  32. package/apps/shared/relay/VideoSender.ts +216 -0
  33. package/apps/shared/relay/audioResume.ts +55 -0
  34. package/apps/shared/relay/frames.ts +275 -0
  35. package/apps/shared/relay/relayEnv.ts +230 -0
  36. package/apps/shared/relay/senderCommon.ts +30 -0
  37. package/apps/shared/webrtc/MeshConnectionManager.ts +159 -7
  38. package/apps/shared/webrtc/realPeerConnection.ts +22 -0
  39. package/apps/shared/webrtc/types.ts +32 -2
  40. package/dist/apps/meet/[token].d.ts +3 -3
  41. package/dist/apps/meet/[token].js +10 -10
  42. package/dist/apps/meet/_CallControls.d.ts +2 -1
  43. package/dist/apps/meet/_CallControls.js +6 -3
  44. package/dist/apps/meet/_CallView.d.ts +4 -1
  45. package/dist/apps/meet/_CallView.js +20 -3
  46. package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
  47. package/dist/apps/meet/_EffectsPanel.js +60 -0
  48. package/dist/apps/meet/_MeetChrome.d.ts +1 -1
  49. package/dist/apps/meet/_MeetLobby.d.ts +8 -0
  50. package/dist/apps/meet/_MeetLobby.js +15 -4
  51. package/dist/apps/meet/_ParticipantTile.d.ts +17 -2
  52. package/dist/apps/meet/_ParticipantTile.js +27 -4
  53. package/dist/apps/meet/_icons.d.ts +1 -0
  54. package/dist/apps/meet/_icons.js +1 -0
  55. package/dist/apps/meet/_layout.d.ts +1 -1
  56. package/dist/apps/meet/_layout.js +1 -1
  57. package/dist/apps/meet/_meetApi.d.ts +6 -0
  58. package/dist/apps/meet/_meetApi.js +1 -1
  59. package/dist/apps/meet/index.js +2 -2
  60. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +1 -1
  61. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +4 -4
  62. package/dist/apps/settings-video-conferencing/_layout.d.ts +1 -1
  63. package/dist/apps/settings-video-conferencing/_layout.js +1 -1
  64. package/dist/apps/settings-video-conferencing/index.d.ts +1 -1
  65. package/dist/apps/settings-video-conferencing/index.js +3 -3
  66. package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +78 -0
  67. package/dist/apps/shared/media/filters/VideoFilterProcessor.js +329 -0
  68. package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
  69. package/dist/apps/shared/media/filters/accessories.js +211 -0
  70. package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
  71. package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
  72. package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
  73. package/dist/apps/shared/media/filters/filterTypes.js +63 -0
  74. package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
  75. package/dist/apps/shared/media/filters/mlModels.js +106 -0
  76. package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
  77. package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
  78. package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
  79. package/dist/apps/shared/media/mediaPreferences.js +93 -0
  80. package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
  81. package/dist/apps/shared/media/useLocalMedia.js +160 -14
  82. package/dist/apps/shared/push/GuestSignalingClient.d.ts +1 -1
  83. package/dist/apps/shared/push/GuestSignalingClient.js +3 -3
  84. package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
  85. package/dist/apps/shared/relay/AudioPlayer.js +103 -0
  86. package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
  87. package/dist/apps/shared/relay/AudioSender.js +172 -0
  88. package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
  89. package/dist/apps/shared/relay/RelayClient.js +236 -0
  90. package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
  91. package/dist/apps/shared/relay/RelayReceiver.js +59 -0
  92. package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
  93. package/dist/apps/shared/relay/RelaySender.js +54 -0
  94. package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
  95. package/dist/apps/shared/relay/RelayTransport.js +142 -0
  96. package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
  97. package/dist/apps/shared/relay/VideoPlayer.js +102 -0
  98. package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
  99. package/dist/apps/shared/relay/VideoSender.js +204 -0
  100. package/dist/apps/shared/relay/audioResume.d.ts +12 -0
  101. package/dist/apps/shared/relay/audioResume.js +45 -0
  102. package/dist/apps/shared/relay/frames.d.ts +101 -0
  103. package/dist/apps/shared/relay/frames.js +221 -0
  104. package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
  105. package/dist/apps/shared/relay/relayEnv.js +69 -0
  106. package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
  107. package/dist/apps/shared/relay/senderCommon.js +10 -0
  108. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +52 -2
  109. package/dist/apps/shared/webrtc/MeshConnectionManager.js +138 -7
  110. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  111. package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
  112. package/dist/apps/shared/webrtc/types.d.ts +29 -2
  113. package/dist/lib/index.js +2 -0
  114. package/dist/lib/index.js.map +1 -1
  115. package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
  116. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  117. package/dist/lib/util/EffectsUtils.js +21 -0
  118. package/dist/lib/util/EffectsUtils.js.map +1 -0
  119. package/dist/lib/util/IceServerUtils.js +25 -4
  120. package/dist/lib/util/IceServerUtils.js.map +1 -1
  121. package/dist/lib/util/RedisRelayBus.js +241 -0
  122. package/dist/lib/util/RedisRelayBus.js.map +1 -0
  123. package/dist/lib/util/RelayHub.js +405 -0
  124. package/dist/lib/util/RelayHub.js.map +1 -0
  125. package/dist/types/index.d.ts +2 -0
  126. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
  127. package/dist/types/util/EffectsUtils.d.ts +10 -0
  128. package/dist/types/util/IceServerUtils.d.ts +12 -1
  129. package/dist/types/util/RedisRelayBus.d.ts +131 -0
  130. package/dist/types/util/RelayHub.d.ts +188 -0
  131. package/package.json +25 -11
@@ -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
+ }
@@ -0,0 +1,69 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The picture behind a participant for the "custom image" background: read from a file they pick on their own
7
+ * device, shrunk to what a video call can show anyway, and kept as a small JPEG data URL so it can be remembered
8
+ * (`mediaPreferences.ts`) without filling the browser's storage. The file never leaves the browser.
9
+ */
10
+ import type { BackgroundPicture } from "./VideoFilterProcessor.js";
11
+
12
+ /** A bigger file than this is refused before the browser tries to decode it. */
13
+ export const MAX_BACKGROUND_FILE_BYTES = 20 * 1024 * 1024;
14
+ /** The longest edge kept - a call's video is 1280 wide, so more would only cost storage. */
15
+ export const MAX_BACKGROUND_EDGE = 1280;
16
+ const JPEG_QUALITY = 0.85;
17
+
18
+ export type PreparedBackground = { ok: true; dataUrl: string; picture: BackgroundPicture } | { ok: false; message: string };
19
+
20
+ /** Decodes an image (a URL or data URL) into something that can be drawn. Rejects if it isn't a picture. */
21
+ export function loadPicture(src: string): Promise<BackgroundPicture> {
22
+ return new Promise((resolve, reject) => {
23
+ const image = new Image();
24
+ image.onload = () => resolve({ source: image, width: image.naturalWidth, height: image.naturalHeight });
25
+ image.onerror = () => reject(new Error("The image could not be read."));
26
+ image.src = src;
27
+ });
28
+ }
29
+
30
+ function readAsDataUrl(file: File): Promise<string> {
31
+ return new Promise((resolve, reject) => {
32
+ const reader = new FileReader();
33
+ reader.onload = () => resolve(String(reader.result));
34
+ reader.onerror = () => reject(new Error("The file could not be read."));
35
+ reader.readAsDataURL(file);
36
+ });
37
+ }
38
+
39
+ /** Reads a picked file into a background: validated, downscaled and ready both to draw and to store. */
40
+ export async function prepareBackgroundImage(file: File): Promise<PreparedBackground> {
41
+ if (!file.type.startsWith("image/")) {
42
+ return { ok: false, message: "Choose an image file (a JPEG, PNG or similar)." };
43
+ }
44
+ if (file.size > MAX_BACKGROUND_FILE_BYTES) {
45
+ return { ok: false, message: "That image is too large. Choose one under 20 MB." };
46
+ }
47
+ try {
48
+ const original = await loadPicture(await readAsDataUrl(file));
49
+ const scale = Math.min(1, MAX_BACKGROUND_EDGE / Math.max(original.width, original.height));
50
+ const canvas = document.createElement("canvas");
51
+ canvas.width = Math.max(1, Math.round(original.width * scale));
52
+ canvas.height = Math.max(1, Math.round(original.height * scale));
53
+ const ctx = canvas.getContext("2d");
54
+ if (!ctx) {
55
+ return { ok: false, message: "This browser can't prepare that image." };
56
+ }
57
+ // JPEG has no transparency: a transparent PNG would otherwise turn black.
58
+ ctx.fillStyle = "#ffffff";
59
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
60
+ ctx.drawImage(original.source, 0, 0, canvas.width, canvas.height);
61
+ return {
62
+ ok: true,
63
+ dataUrl: canvas.toDataURL("image/jpeg", JPEG_QUALITY),
64
+ picture: { source: canvas, width: canvas.width, height: canvas.height },
65
+ };
66
+ } catch {
67
+ return { ok: false, message: "That file couldn't be opened as an image." };
68
+ }
69
+ }
@@ -0,0 +1,89 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The video filters a participant can put on their camera. They come in three independent layers, so they combine
7
+ * (blurred background *and* black and white *and* sunglasses):
8
+ *
9
+ * - **Background**: what is behind the participant - untouched, blurred, or a picture they chose. Needs person
10
+ * segmentation (`mlModels.ts`).
11
+ * - **Effect**: a look applied to the whole picture - black and white, sepia, night vision, pixelated.
12
+ * - **Accessory**: something drawn on the participant's face - sunglasses, cat ears, a party hat. Needs face landmarks
13
+ * (`mlModels.ts`).
14
+ *
15
+ * Adding a filter is: add its id here (with a label), then implement it in `VideoFilterProcessor.ts` (an effect), or
16
+ * in `accessories.ts` (an accessory).
17
+ */
18
+
19
+ export type BackgroundFilter = "none" | "blur" | "image";
20
+ export type ColorEffect = "none" | "bw" | "sepia" | "night-vision" | "pixelate";
21
+ export type Accessory = "none" | "sunglasses" | "cat-ears" | "party-hat" | "crown" | "mustache";
22
+
23
+ export interface VideoFilters {
24
+ background: BackgroundFilter;
25
+ effect: ColorEffect;
26
+ accessory: Accessory;
27
+ }
28
+
29
+ export const NO_FILTERS: VideoFilters = { background: "none", effect: "none", accessory: "none" };
30
+
31
+ export interface FilterOption<T extends string> {
32
+ id: T;
33
+ label: string;
34
+ /** A glyph shown beside the label, purely decorative. */
35
+ emoji: string;
36
+ }
37
+
38
+ export const BACKGROUND_OPTIONS: FilterOption<BackgroundFilter>[] = [
39
+ { id: "none", label: "None", emoji: "🚫" },
40
+ { id: "blur", label: "Blur", emoji: "🌫️" },
41
+ { id: "image", label: "Custom image", emoji: "🖼️" },
42
+ ];
43
+
44
+ export const EFFECT_OPTIONS: FilterOption<ColorEffect>[] = [
45
+ { id: "none", label: "None", emoji: "🚫" },
46
+ { id: "bw", label: "Black & white", emoji: "🎞️" },
47
+ { id: "sepia", label: "Sepia", emoji: "📜" },
48
+ { id: "night-vision", label: "Night vision", emoji: "🥽" },
49
+ { id: "pixelate", label: "Pixelate", emoji: "👾" },
50
+ ];
51
+
52
+ export const ACCESSORY_OPTIONS: FilterOption<Accessory>[] = [
53
+ { id: "none", label: "None", emoji: "🚫" },
54
+ { id: "sunglasses", label: "Sunglasses", emoji: "😎" },
55
+ { id: "cat-ears", label: "Cat", emoji: "🐱" },
56
+ { id: "party-hat", label: "Party hat", emoji: "🥳" },
57
+ { id: "crown", label: "Crown", emoji: "👑" },
58
+ { id: "mustache", label: "Mustache", emoji: "🥸" },
59
+ ];
60
+
61
+ /** Whether any filter is on - when none is, the camera's own track is sent untouched and nothing is processed. */
62
+ export function filtersActive(filters: VideoFilters): boolean {
63
+ return filters.background !== "none" || filters.effect !== "none" || filters.accessory !== "none";
64
+ }
65
+
66
+ /** Whether the filters need the person-segmentation model. */
67
+ export function needsSegmentation(filters: VideoFilters): boolean {
68
+ return filters.background !== "none";
69
+ }
70
+
71
+ /** Whether the filters need the face-landmark model. */
72
+ export function needsFace(filters: VideoFilters): boolean {
73
+ return filters.accessory !== "none";
74
+ }
75
+
76
+ function pick<T extends string>(options: FilterOption<T>[], value: unknown, fallback: T): T {
77
+ return options.some((option) => option.id === value) ? (value as T) : fallback;
78
+ }
79
+
80
+ /** Turns whatever was read back from storage into valid filters - anything unrecognised (a filter since removed, a
81
+ * hand-edited value, another version's shape) falls back to "none" rather than breaking the call. */
82
+ export function sanitizeFilters(value: unknown): VideoFilters {
83
+ const raw = (typeof value === "object" && value !== null ? value : {}) as Record<string, unknown>;
84
+ return {
85
+ background: pick(BACKGROUND_OPTIONS, raw.background, "none"),
86
+ effect: pick(EFFECT_OPTIONS, raw.effect, "none"),
87
+ accessory: pick(ACCESSORY_OPTIONS, raw.accessory, "none"),
88
+ };
89
+ }
@@ -0,0 +1,174 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The two machine-learning models behind the filters, run in the browser with MediaPipe Tasks:
7
+ *
8
+ * - **Person segmentation** (`createPersonSegmenter`) - which pixels are the participant: what the background
9
+ * blur and the custom background need.
10
+ * - **Face landmarks** (`createFaceTracker`) - where the participant's face is: what the accessories need.
11
+ *
12
+ * Nothing here is loaded until a participant turns a filter on that needs it, and each model is loaded once and
13
+ * shared. The MediaPipe library itself is `import()`ed on demand for the same reason - a participant who never uses a
14
+ * filter never downloads it.
15
+ *
16
+ * **Where the assets come from**: by default the WebAssembly runtime is fetched from jsDelivr and the models from
17
+ * Google's public MediaPipe model bucket. That is a request to a third party, so an administrator can host them
18
+ * instead (the `mail:videoconf:effects:assets_url` setting, delivered by the join response): the runtime's `wasm/`
19
+ * directory from the `@mediapipe/tasks-vision` package, plus `selfie_segmenter.tflite` and `face_landmarker.task`,
20
+ * all under one base URL.
21
+ *
22
+ * Everything the rest of the pipeline needs from MediaPipe is behind the small `PersonSegmenter`/`FaceTracker`
23
+ * interfaces, so `VideoFilterProcessor` never touches the library and its tests use fakes.
24
+ */
25
+ import type { FaceLandmark } from "./accessories.js";
26
+
27
+ /** Must match the installed `@mediapipe/tasks-vision` (a test checks): the `.wasm` fetched at runtime has to be the
28
+ * version the JavaScript was built against. */
29
+ export const MEDIAPIPE_VERSION = "1.0.1";
30
+
31
+ const CDN_WASM = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_VERSION}/wasm`;
32
+ const CDN_SEGMENTER_MODEL =
33
+ "https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite";
34
+ const CDN_FACE_MODEL =
35
+ "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task";
36
+
37
+ export interface ModelUrls {
38
+ wasm: string;
39
+ segmenter: string;
40
+ face: string;
41
+ }
42
+
43
+ /** Where to fetch the runtime and models from - the CDN when `assetsUrl` is empty, else under `assetsUrl`. */
44
+ export function modelUrls(assetsUrl?: string): ModelUrls {
45
+ if (!assetsUrl) {
46
+ return { wasm: CDN_WASM, segmenter: CDN_SEGMENTER_MODEL, face: CDN_FACE_MODEL };
47
+ }
48
+ const base = assetsUrl.replace(/\/+$/, "");
49
+ return { wasm: `${base}/wasm`, segmenter: `${base}/selfie_segmenter.tflite`, face: `${base}/face_landmarker.task` };
50
+ }
51
+
52
+ /** How likely each pixel of a frame is to be the participant, 0-1. */
53
+ export interface PersonMask {
54
+ width: number;
55
+ height: number;
56
+ confidence: Float32Array;
57
+ }
58
+
59
+ export interface PersonSegmenter {
60
+ /** Segments `frame`, or `null` when the model produced nothing for it. */
61
+ segment(frame: HTMLVideoElement, timestampMs: number): PersonMask | null;
62
+ }
63
+
64
+ export interface FaceTracker {
65
+ /** The landmarks of the first face in `frame`, or `null` when there is none. */
66
+ detect(frame: HTMLVideoElement, timestampMs: number): FaceLandmark[] | null;
67
+ }
68
+
69
+ /** Which of a segmentation model's masks is the person: the one labelled as such (the selfie model's only label is
70
+ * "selfie"), else the last (models list the background first). */
71
+ export function personMaskIndex(labels: string[], maskCount: number): number {
72
+ const index = labels.findIndex((label) => /person|selfie|foreground/i.test(label));
73
+ return index >= 0 && index < maskCount ? index : maskCount - 1;
74
+ }
75
+
76
+ /** MediaPipe rejects a frame whose timestamp isn't later than the last one it saw. */
77
+ function monotonic(): (timestampMs: number) => number {
78
+ let last = -1;
79
+ return (timestampMs) => {
80
+ last = Math.max(timestampMs, last + 1);
81
+ return last;
82
+ };
83
+ }
84
+
85
+ type Vision = typeof import("@mediapipe/tasks-vision");
86
+
87
+ /** Creates a task on the GPU, or on the CPU when the GPU can't run it (no WebGL, or a driver that refuses). */
88
+ async function withFallback<T>(create: (delegate: "GPU" | "CPU") => Promise<T>): Promise<T> {
89
+ try {
90
+ return await create("GPU");
91
+ } catch {
92
+ return create("CPU");
93
+ }
94
+ }
95
+
96
+ async function loadSegmenter(assetsUrl?: string): Promise<PersonSegmenter> {
97
+ const urls = modelUrls(assetsUrl);
98
+ const vision: Vision = await import("@mediapipe/tasks-vision");
99
+ const fileset = await vision.FilesetResolver.forVisionTasks(urls.wasm);
100
+ const segmenter = await withFallback((delegate) =>
101
+ vision.ImageSegmenter.createFromOptions(fileset, {
102
+ baseOptions: { modelAssetPath: urls.segmenter, delegate },
103
+ runningMode: "VIDEO",
104
+ outputConfidenceMasks: true,
105
+ outputCategoryMask: false,
106
+ }),
107
+ );
108
+ const labels = segmenter.getLabels();
109
+ const stamp = monotonic();
110
+ return {
111
+ segment(frame, timestampMs) {
112
+ let mask: PersonMask | null = null;
113
+ segmenter.segmentForVideo(frame, stamp(timestampMs), (result) => {
114
+ const masks = result.confidenceMasks;
115
+ if (!masks?.length) {
116
+ return;
117
+ }
118
+ const person = masks[personMaskIndex(labels, masks.length)];
119
+ // The mask is only valid inside this callback, so it is copied out.
120
+ mask = { width: person.width, height: person.height, confidence: new Float32Array(person.getAsFloat32Array()) };
121
+ });
122
+ return mask;
123
+ },
124
+ };
125
+ }
126
+
127
+ async function loadFaceTracker(assetsUrl?: string): Promise<FaceTracker> {
128
+ const urls = modelUrls(assetsUrl);
129
+ const vision: Vision = await import("@mediapipe/tasks-vision");
130
+ const fileset = await vision.FilesetResolver.forVisionTasks(urls.wasm);
131
+ const landmarker = await withFallback((delegate) =>
132
+ vision.FaceLandmarker.createFromOptions(fileset, {
133
+ baseOptions: { modelAssetPath: urls.face, delegate },
134
+ runningMode: "VIDEO",
135
+ numFaces: 1,
136
+ }),
137
+ );
138
+ const stamp = monotonic();
139
+ return {
140
+ detect(frame, timestampMs) {
141
+ return landmarker.detectForVideo(frame, stamp(timestampMs)).faceLandmarks[0] ?? null;
142
+ },
143
+ };
144
+ }
145
+
146
+ const segmenters = new Map<string, Promise<PersonSegmenter>>();
147
+ const faceTrackers = new Map<string, Promise<FaceTracker>>();
148
+
149
+ /** Loads `load` once per key, and forgets a failure so the next attempt (a retry) tries again. */
150
+ function shared<T>(cache: Map<string, Promise<T>>, key: string, load: () => Promise<T>): Promise<T> {
151
+ let pending = cache.get(key);
152
+ if (!pending) {
153
+ pending = load();
154
+ cache.set(key, pending);
155
+ pending.catch(() => cache.delete(key));
156
+ }
157
+ return pending;
158
+ }
159
+
160
+ /** The person-segmentation model, loaded on first use and shared from then on. Rejects if it can't be loaded. */
161
+ export function createPersonSegmenter(assetsUrl?: string): Promise<PersonSegmenter> {
162
+ return shared(segmenters, assetsUrl ?? "", () => loadSegmenter(assetsUrl));
163
+ }
164
+
165
+ /** The face-landmark model, loaded on first use and shared from then on. Rejects if it can't be loaded. */
166
+ export function createFaceTracker(assetsUrl?: string): Promise<FaceTracker> {
167
+ return shared(faceTrackers, assetsUrl ?? "", () => loadFaceTracker(assetsUrl));
168
+ }
169
+
170
+ /** Forgets every loaded model - for tests. */
171
+ export function resetModelCache(): void {
172
+ segmenters.clear();
173
+ faceTrackers.clear();
174
+ }
@@ -0,0 +1,61 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The colour effects, as plain functions over an RGBA pixel buffer (`ImageData.data`) so they need no canvas API
7
+ * beyond `getImageData`/`putImageData`. They are done here, one pixel at a time, rather than with the canvas
8
+ * `filter` property, which Safari does not support.
9
+ *
10
+ * Each function edits `data` in place and leaves the alpha channel alone.
11
+ */
12
+
13
+ /** Rec. 601 luma - how bright a colour looks. */
14
+ function luma(r: number, g: number, b: number): number {
15
+ return 0.299 * r + 0.587 * g + 0.114 * b;
16
+ }
17
+
18
+ export function applyGrayscale(data: Uint8ClampedArray): void {
19
+ for (let i = 0; i < data.length; i += 4) {
20
+ const y = luma(data[i], data[i + 1], data[i + 2]);
21
+ data[i] = y;
22
+ data[i + 1] = y;
23
+ data[i + 2] = y;
24
+ }
25
+ }
26
+
27
+ export function applySepia(data: Uint8ClampedArray): void {
28
+ for (let i = 0; i < data.length; i += 4) {
29
+ const r = data[i];
30
+ const g = data[i + 1];
31
+ const b = data[i + 2];
32
+ // `Uint8ClampedArray` clamps to 0-255 on assignment.
33
+ data[i] = 0.393 * r + 0.769 * g + 0.189 * b;
34
+ data[i + 1] = 0.349 * r + 0.686 * g + 0.168 * b;
35
+ data[i + 2] = 0.272 * r + 0.534 * g + 0.131 * b;
36
+ }
37
+ }
38
+
39
+ /** A brightened, green-tinted picture, like a night-vision scope. */
40
+ export function applyNightVision(data: Uint8ClampedArray): void {
41
+ for (let i = 0; i < data.length; i += 4) {
42
+ const y = luma(data[i], data[i + 1], data[i + 2]) * 1.4;
43
+ data[i] = y * 0.25;
44
+ data[i + 1] = y;
45
+ data[i + 2] = y * 0.25;
46
+ }
47
+ }
48
+
49
+ /** Below this confidence a pixel is background; above `MASK_HIGH` it is the person; in between it fades, so the edge of
50
+ * the person is soft rather than jagged. */
51
+ const MASK_LOW = 0.3;
52
+ const MASK_HIGH = 0.7;
53
+
54
+ /** Turns a person-confidence mask (0-1 per pixel) into the alpha channel of an RGBA buffer, sharpening the edge with a
55
+ * smoothstep. `rgba` must hold `4 * confidence.length` values. */
56
+ export function maskToAlpha(confidence: Float32Array, rgba: Uint8ClampedArray): void {
57
+ for (let i = 0; i < confidence.length; i++) {
58
+ const t = Math.min(1, Math.max(0, (confidence[i] - MASK_LOW) / (MASK_HIGH - MASK_LOW)));
59
+ rgba[i * 4 + 3] = t * t * (3 - 2 * t) * 255;
60
+ }
61
+ }