@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,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
+ }
@@ -0,0 +1,105 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * What a participant chose last time - which camera and microphone, whether each was on, and their video filters -
7
+ * kept in this browser's `localStorage` so the next meeting starts the same way.
8
+ *
9
+ * It lives on the device rather than on the server on purpose: most participants are guests with no account, and a
10
+ * camera's id means nothing on another machine anyway. Nothing here is sent anywhere.
11
+ *
12
+ * Storage can be missing or refuse to work (private windows, blocked site data, a full quota, server-side rendering),
13
+ * and none of that is worth failing a call over: every function tolerates it and behaves as if nothing was saved.
14
+ * Whatever is read back is validated, since it may be from another version of this plugin or edited by hand.
15
+ *
16
+ * The custom background picture is stored under its own key: it is by far the largest thing kept, and if it doesn't
17
+ * fit the rest of the settings should still be saved.
18
+ */
19
+ import { type VideoFilters, sanitizeFilters } from "./filters/filterTypes.js";
20
+
21
+ export interface MediaPreferences {
22
+ /** The camera and microphone the participant picked, by `deviceId`. */
23
+ cameraId?: string;
24
+ microphoneId?: string;
25
+ /** Whether the microphone / camera was on when they left. */
26
+ micEnabled?: boolean;
27
+ cameraEnabled?: boolean;
28
+ filters?: VideoFilters;
29
+ /** The custom background, as a (downscaled) image data URL. */
30
+ backgroundImage?: string;
31
+ }
32
+
33
+ export const PREFERENCES_KEY = "rapidmx.meet.preferences";
34
+ export const BACKGROUND_KEY = "rapidmx.meet.background";
35
+
36
+ /** The `Storage` to use, or `undefined` where there is none - looked up lazily and guarded, since merely reading
37
+ * `localStorage` can throw (a browser blocking site data) and it doesn't exist on the server. */
38
+ export function defaultStorage(): Storage | undefined {
39
+ try {
40
+ return typeof localStorage === "undefined" ? undefined : localStorage;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ function readJson(storage: Storage, key: string): Record<string, unknown> {
47
+ try {
48
+ const parsed: unknown = JSON.parse(storage.getItem(key) ?? "{}");
49
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
50
+ } catch {
51
+ return {};
52
+ }
53
+ }
54
+
55
+ const text = (value: unknown): string | undefined => (typeof value === "string" && value ? value : undefined);
56
+ const flag = (value: unknown): boolean | undefined => (typeof value === "boolean" ? value : undefined);
57
+
58
+ export function loadPreferences(storage: Storage | undefined = defaultStorage()): MediaPreferences {
59
+ if (!storage) {
60
+ return {};
61
+ }
62
+ const raw = readJson(storage, PREFERENCES_KEY);
63
+ let backgroundImage: string | undefined;
64
+ try {
65
+ const stored = storage.getItem(BACKGROUND_KEY);
66
+ backgroundImage = stored?.startsWith("data:image/") ? stored : undefined;
67
+ } catch {
68
+ backgroundImage = undefined;
69
+ }
70
+ return {
71
+ cameraId: text(raw.cameraId),
72
+ microphoneId: text(raw.microphoneId),
73
+ micEnabled: flag(raw.micEnabled),
74
+ cameraEnabled: flag(raw.cameraEnabled),
75
+ filters: raw.filters === undefined ? undefined : sanitizeFilters(raw.filters),
76
+ backgroundImage,
77
+ };
78
+ }
79
+
80
+ /** Merges `patch` into what is saved. A key set to `undefined` is forgotten; a failure to save is ignored. */
81
+ export function savePreferences(patch: Partial<MediaPreferences>, storage: Storage | undefined = defaultStorage()): void {
82
+ if (!storage) {
83
+ return;
84
+ }
85
+ const { backgroundImage, ...rest } = patch;
86
+ if (Object.keys(rest).length > 0) {
87
+ try {
88
+ const merged = { ...readJson(storage, PREFERENCES_KEY), ...rest };
89
+ storage.setItem(PREFERENCES_KEY, JSON.stringify(merged));
90
+ } catch {
91
+ // Nowhere to save - the settings just won't carry over.
92
+ }
93
+ }
94
+ if ("backgroundImage" in patch) {
95
+ try {
96
+ if (backgroundImage) {
97
+ storage.setItem(BACKGROUND_KEY, backgroundImage);
98
+ } else {
99
+ storage.removeItem(BACKGROUND_KEY);
100
+ }
101
+ } catch {
102
+ // Most likely the image is too big for what is left of the quota.
103
+ }
104
+ }
105
+ }