@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,63 @@
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
+ export const NO_FILTERS = { background: "none", effect: "none", accessory: "none" };
19
+ export const BACKGROUND_OPTIONS = [
20
+ { id: "none", label: "None", emoji: "🚫" },
21
+ { id: "blur", label: "Blur", emoji: "🌫️" },
22
+ { id: "image", label: "Custom image", emoji: "🖼️" },
23
+ ];
24
+ export const EFFECT_OPTIONS = [
25
+ { id: "none", label: "None", emoji: "🚫" },
26
+ { id: "bw", label: "Black & white", emoji: "🎞️" },
27
+ { id: "sepia", label: "Sepia", emoji: "📜" },
28
+ { id: "night-vision", label: "Night vision", emoji: "🥽" },
29
+ { id: "pixelate", label: "Pixelate", emoji: "👾" },
30
+ ];
31
+ export const ACCESSORY_OPTIONS = [
32
+ { id: "none", label: "None", emoji: "🚫" },
33
+ { id: "sunglasses", label: "Sunglasses", emoji: "😎" },
34
+ { id: "cat-ears", label: "Cat", emoji: "🐱" },
35
+ { id: "party-hat", label: "Party hat", emoji: "🥳" },
36
+ { id: "crown", label: "Crown", emoji: "👑" },
37
+ { id: "mustache", label: "Mustache", emoji: "🥸" },
38
+ ];
39
+ /** Whether any filter is on - when none is, the camera's own track is sent untouched and nothing is processed. */
40
+ export function filtersActive(filters) {
41
+ return filters.background !== "none" || filters.effect !== "none" || filters.accessory !== "none";
42
+ }
43
+ /** Whether the filters need the person-segmentation model. */
44
+ export function needsSegmentation(filters) {
45
+ return filters.background !== "none";
46
+ }
47
+ /** Whether the filters need the face-landmark model. */
48
+ export function needsFace(filters) {
49
+ return filters.accessory !== "none";
50
+ }
51
+ function pick(options, value, fallback) {
52
+ return options.some((option) => option.id === value) ? value : fallback;
53
+ }
54
+ /** Turns whatever was read back from storage into valid filters - anything unrecognised (a filter since removed, a
55
+ * hand-edited value, another version's shape) falls back to "none" rather than breaking the call. */
56
+ export function sanitizeFilters(value) {
57
+ const raw = (typeof value === "object" && value !== null ? value : {});
58
+ return {
59
+ background: pick(BACKGROUND_OPTIONS, raw.background, "none"),
60
+ effect: pick(EFFECT_OPTIONS, raw.effect, "none"),
61
+ accessory: pick(ACCESSORY_OPTIONS, raw.accessory, "none"),
62
+ };
63
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The two machine-learning models behind the filters, run in the browser with MediaPipe Tasks:
3
+ *
4
+ * - **Person segmentation** (`createPersonSegmenter`) - which pixels are the participant: what the background
5
+ * blur and the custom background need.
6
+ * - **Face landmarks** (`createFaceTracker`) - where the participant's face is: what the accessories need.
7
+ *
8
+ * Nothing here is loaded until a participant turns a filter on that needs it, and each model is loaded once and
9
+ * shared. The MediaPipe library itself is `import()`ed on demand for the same reason - a participant who never uses a
10
+ * filter never downloads it.
11
+ *
12
+ * **Where the assets come from**: by default the WebAssembly runtime is fetched from jsDelivr and the models from
13
+ * Google's public MediaPipe model bucket. That is a request to a third party, so an administrator can host them
14
+ * instead (the `mail:videoconf:effects:assets_url` setting, delivered by the join response): the runtime's `wasm/`
15
+ * directory from the `@mediapipe/tasks-vision` package, plus `selfie_segmenter.tflite` and `face_landmarker.task`,
16
+ * all under one base URL.
17
+ *
18
+ * Everything the rest of the pipeline needs from MediaPipe is behind the small `PersonSegmenter`/`FaceTracker`
19
+ * interfaces, so `VideoFilterProcessor` never touches the library and its tests use fakes.
20
+ */
21
+ import type { FaceLandmark } from "./accessories.js";
22
+ /** Must match the installed `@mediapipe/tasks-vision` (a test checks): the `.wasm` fetched at runtime has to be the
23
+ * version the JavaScript was built against. */
24
+ export declare const MEDIAPIPE_VERSION = "1.0.1";
25
+ export interface ModelUrls {
26
+ wasm: string;
27
+ segmenter: string;
28
+ face: string;
29
+ }
30
+ /** Where to fetch the runtime and models from - the CDN when `assetsUrl` is empty, else under `assetsUrl`. */
31
+ export declare function modelUrls(assetsUrl?: string): ModelUrls;
32
+ /** How likely each pixel of a frame is to be the participant, 0-1. */
33
+ export interface PersonMask {
34
+ width: number;
35
+ height: number;
36
+ confidence: Float32Array;
37
+ }
38
+ export interface PersonSegmenter {
39
+ /** Segments `frame`, or `null` when the model produced nothing for it. */
40
+ segment(frame: HTMLVideoElement, timestampMs: number): PersonMask | null;
41
+ }
42
+ export interface FaceTracker {
43
+ /** The landmarks of the first face in `frame`, or `null` when there is none. */
44
+ detect(frame: HTMLVideoElement, timestampMs: number): FaceLandmark[] | null;
45
+ }
46
+ /** Which of a segmentation model's masks is the person: the one labelled as such (the selfie model's only label is
47
+ * "selfie"), else the last (models list the background first). */
48
+ export declare function personMaskIndex(labels: string[], maskCount: number): number;
49
+ /** The person-segmentation model, loaded on first use and shared from then on. Rejects if it can't be loaded. */
50
+ export declare function createPersonSegmenter(assetsUrl?: string): Promise<PersonSegmenter>;
51
+ /** The face-landmark model, loaded on first use and shared from then on. Rejects if it can't be loaded. */
52
+ export declare function createFaceTracker(assetsUrl?: string): Promise<FaceTracker>;
53
+ /** Forgets every loaded model - for tests. */
54
+ export declare function resetModelCache(): void;
@@ -0,0 +1,106 @@
1
+ /** Must match the installed `@mediapipe/tasks-vision` (a test checks): the `.wasm` fetched at runtime has to be the
2
+ * version the JavaScript was built against. */
3
+ export const MEDIAPIPE_VERSION = "1.0.1";
4
+ const CDN_WASM = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${MEDIAPIPE_VERSION}/wasm`;
5
+ const CDN_SEGMENTER_MODEL = "https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite";
6
+ const CDN_FACE_MODEL = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task";
7
+ /** Where to fetch the runtime and models from - the CDN when `assetsUrl` is empty, else under `assetsUrl`. */
8
+ export function modelUrls(assetsUrl) {
9
+ if (!assetsUrl) {
10
+ return { wasm: CDN_WASM, segmenter: CDN_SEGMENTER_MODEL, face: CDN_FACE_MODEL };
11
+ }
12
+ const base = assetsUrl.replace(/\/+$/, "");
13
+ return { wasm: `${base}/wasm`, segmenter: `${base}/selfie_segmenter.tflite`, face: `${base}/face_landmarker.task` };
14
+ }
15
+ /** Which of a segmentation model's masks is the person: the one labelled as such (the selfie model's only label is
16
+ * "selfie"), else the last (models list the background first). */
17
+ export function personMaskIndex(labels, maskCount) {
18
+ const index = labels.findIndex((label) => /person|selfie|foreground/i.test(label));
19
+ return index >= 0 && index < maskCount ? index : maskCount - 1;
20
+ }
21
+ /** MediaPipe rejects a frame whose timestamp isn't later than the last one it saw. */
22
+ function monotonic() {
23
+ let last = -1;
24
+ return (timestampMs) => {
25
+ last = Math.max(timestampMs, last + 1);
26
+ return last;
27
+ };
28
+ }
29
+ /** Creates a task on the GPU, or on the CPU when the GPU can't run it (no WebGL, or a driver that refuses). */
30
+ async function withFallback(create) {
31
+ try {
32
+ return await create("GPU");
33
+ }
34
+ catch {
35
+ return create("CPU");
36
+ }
37
+ }
38
+ async function loadSegmenter(assetsUrl) {
39
+ const urls = modelUrls(assetsUrl);
40
+ const vision = await import("@mediapipe/tasks-vision");
41
+ const fileset = await vision.FilesetResolver.forVisionTasks(urls.wasm);
42
+ const segmenter = await withFallback((delegate) => vision.ImageSegmenter.createFromOptions(fileset, {
43
+ baseOptions: { modelAssetPath: urls.segmenter, delegate },
44
+ runningMode: "VIDEO",
45
+ outputConfidenceMasks: true,
46
+ outputCategoryMask: false,
47
+ }));
48
+ const labels = segmenter.getLabels();
49
+ const stamp = monotonic();
50
+ return {
51
+ segment(frame, timestampMs) {
52
+ let mask = null;
53
+ segmenter.segmentForVideo(frame, stamp(timestampMs), (result) => {
54
+ const masks = result.confidenceMasks;
55
+ if (!masks?.length) {
56
+ return;
57
+ }
58
+ const person = masks[personMaskIndex(labels, masks.length)];
59
+ // The mask is only valid inside this callback, so it is copied out.
60
+ mask = { width: person.width, height: person.height, confidence: new Float32Array(person.getAsFloat32Array()) };
61
+ });
62
+ return mask;
63
+ },
64
+ };
65
+ }
66
+ async function loadFaceTracker(assetsUrl) {
67
+ const urls = modelUrls(assetsUrl);
68
+ const vision = await import("@mediapipe/tasks-vision");
69
+ const fileset = await vision.FilesetResolver.forVisionTasks(urls.wasm);
70
+ const landmarker = await withFallback((delegate) => vision.FaceLandmarker.createFromOptions(fileset, {
71
+ baseOptions: { modelAssetPath: urls.face, delegate },
72
+ runningMode: "VIDEO",
73
+ numFaces: 1,
74
+ }));
75
+ const stamp = monotonic();
76
+ return {
77
+ detect(frame, timestampMs) {
78
+ return landmarker.detectForVideo(frame, stamp(timestampMs)).faceLandmarks[0] ?? null;
79
+ },
80
+ };
81
+ }
82
+ const segmenters = new Map();
83
+ const faceTrackers = new Map();
84
+ /** Loads `load` once per key, and forgets a failure so the next attempt (a retry) tries again. */
85
+ function shared(cache, key, load) {
86
+ let pending = cache.get(key);
87
+ if (!pending) {
88
+ pending = load();
89
+ cache.set(key, pending);
90
+ pending.catch(() => cache.delete(key));
91
+ }
92
+ return pending;
93
+ }
94
+ /** The person-segmentation model, loaded on first use and shared from then on. Rejects if it can't be loaded. */
95
+ export function createPersonSegmenter(assetsUrl) {
96
+ return shared(segmenters, assetsUrl ?? "", () => loadSegmenter(assetsUrl));
97
+ }
98
+ /** The face-landmark model, loaded on first use and shared from then on. Rejects if it can't be loaded. */
99
+ export function createFaceTracker(assetsUrl) {
100
+ return shared(faceTrackers, assetsUrl ?? "", () => loadFaceTracker(assetsUrl));
101
+ }
102
+ /** Forgets every loaded model - for tests. */
103
+ export function resetModelCache() {
104
+ segmenters.clear();
105
+ faceTrackers.clear();
106
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The colour effects, as plain functions over an RGBA pixel buffer (`ImageData.data`) so they need no canvas API
3
+ * beyond `getImageData`/`putImageData`. They are done here, one pixel at a time, rather than with the canvas
4
+ * `filter` property, which Safari does not support.
5
+ *
6
+ * Each function edits `data` in place and leaves the alpha channel alone.
7
+ */
8
+ export declare function applyGrayscale(data: Uint8ClampedArray): void;
9
+ export declare function applySepia(data: Uint8ClampedArray): void;
10
+ /** A brightened, green-tinted picture, like a night-vision scope. */
11
+ export declare function applyNightVision(data: Uint8ClampedArray): void;
12
+ /** Turns a person-confidence mask (0-1 per pixel) into the alpha channel of an RGBA buffer, sharpening the edge with a
13
+ * smoothstep. `rgba` must hold `4 * confidence.length` values. */
14
+ export declare function maskToAlpha(confidence: Float32Array, rgba: Uint8ClampedArray): void;
@@ -0,0 +1,55 @@
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
+ /** Rec. 601 luma - how bright a colour looks. */
13
+ function luma(r, g, b) {
14
+ return 0.299 * r + 0.587 * g + 0.114 * b;
15
+ }
16
+ export function applyGrayscale(data) {
17
+ for (let i = 0; i < data.length; i += 4) {
18
+ const y = luma(data[i], data[i + 1], data[i + 2]);
19
+ data[i] = y;
20
+ data[i + 1] = y;
21
+ data[i + 2] = y;
22
+ }
23
+ }
24
+ export function applySepia(data) {
25
+ for (let i = 0; i < data.length; i += 4) {
26
+ const r = data[i];
27
+ const g = data[i + 1];
28
+ const b = data[i + 2];
29
+ // `Uint8ClampedArray` clamps to 0-255 on assignment.
30
+ data[i] = 0.393 * r + 0.769 * g + 0.189 * b;
31
+ data[i + 1] = 0.349 * r + 0.686 * g + 0.168 * b;
32
+ data[i + 2] = 0.272 * r + 0.534 * g + 0.131 * b;
33
+ }
34
+ }
35
+ /** A brightened, green-tinted picture, like a night-vision scope. */
36
+ export function applyNightVision(data) {
37
+ for (let i = 0; i < data.length; i += 4) {
38
+ const y = luma(data[i], data[i + 1], data[i + 2]) * 1.4;
39
+ data[i] = y * 0.25;
40
+ data[i + 1] = y;
41
+ data[i + 2] = y * 0.25;
42
+ }
43
+ }
44
+ /** Below this confidence a pixel is background; above `MASK_HIGH` it is the person; in between it fades, so the edge of
45
+ * the person is soft rather than jagged. */
46
+ const MASK_LOW = 0.3;
47
+ const MASK_HIGH = 0.7;
48
+ /** Turns a person-confidence mask (0-1 per pixel) into the alpha channel of an RGBA buffer, sharpening the edge with a
49
+ * smoothstep. `rgba` must hold `4 * confidence.length` values. */
50
+ export function maskToAlpha(confidence, rgba) {
51
+ for (let i = 0; i < confidence.length; i++) {
52
+ const t = Math.min(1, Math.max(0, (confidence[i] - MASK_LOW) / (MASK_HIGH - MASK_LOW)));
53
+ rgba[i * 4 + 3] = t * t * (3 - 2 * t) * 255;
54
+ }
55
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * What a participant chose last time - which camera and microphone, whether each was on, and their video filters -
3
+ * kept in this browser's `localStorage` so the next meeting starts the same way.
4
+ *
5
+ * It lives on the device rather than on the server on purpose: most participants are guests with no account, and a
6
+ * camera's id means nothing on another machine anyway. Nothing here is sent anywhere.
7
+ *
8
+ * Storage can be missing or refuse to work (private windows, blocked site data, a full quota, server-side rendering),
9
+ * and none of that is worth failing a call over: every function tolerates it and behaves as if nothing was saved.
10
+ * Whatever is read back is validated, since it may be from another version of this plugin or edited by hand.
11
+ *
12
+ * The custom background picture is stored under its own key: it is by far the largest thing kept, and if it doesn't
13
+ * fit the rest of the settings should still be saved.
14
+ */
15
+ import { type VideoFilters } from "./filters/filterTypes.js";
16
+ export interface MediaPreferences {
17
+ /** The camera and microphone the participant picked, by `deviceId`. */
18
+ cameraId?: string;
19
+ microphoneId?: string;
20
+ /** Whether the microphone / camera was on when they left. */
21
+ micEnabled?: boolean;
22
+ cameraEnabled?: boolean;
23
+ filters?: VideoFilters;
24
+ /** The custom background, as a (downscaled) image data URL. */
25
+ backgroundImage?: string;
26
+ }
27
+ export declare const PREFERENCES_KEY = "rapidmx.meet.preferences";
28
+ export declare const BACKGROUND_KEY = "rapidmx.meet.background";
29
+ /** The `Storage` to use, or `undefined` where there is none - looked up lazily and guarded, since merely reading
30
+ * `localStorage` can throw (a browser blocking site data) and it doesn't exist on the server. */
31
+ export declare function defaultStorage(): Storage | undefined;
32
+ export declare function loadPreferences(storage?: Storage | undefined): MediaPreferences;
33
+ /** Merges `patch` into what is saved. A key set to `undefined` is forgotten; a failure to save is ignored. */
34
+ export declare function savePreferences(patch: Partial<MediaPreferences>, storage?: Storage | undefined): void;
@@ -0,0 +1,93 @@
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 { sanitizeFilters } from "./filters/filterTypes.js";
20
+ export const PREFERENCES_KEY = "rapidmx.meet.preferences";
21
+ export const BACKGROUND_KEY = "rapidmx.meet.background";
22
+ /** The `Storage` to use, or `undefined` where there is none - looked up lazily and guarded, since merely reading
23
+ * `localStorage` can throw (a browser blocking site data) and it doesn't exist on the server. */
24
+ export function defaultStorage() {
25
+ try {
26
+ return typeof localStorage === "undefined" ? undefined : localStorage;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ function readJson(storage, key) {
33
+ try {
34
+ const parsed = JSON.parse(storage.getItem(key) ?? "{}");
35
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
36
+ }
37
+ catch {
38
+ return {};
39
+ }
40
+ }
41
+ const text = (value) => (typeof value === "string" && value ? value : undefined);
42
+ const flag = (value) => (typeof value === "boolean" ? value : undefined);
43
+ export function loadPreferences(storage = defaultStorage()) {
44
+ if (!storage) {
45
+ return {};
46
+ }
47
+ const raw = readJson(storage, PREFERENCES_KEY);
48
+ let backgroundImage;
49
+ try {
50
+ const stored = storage.getItem(BACKGROUND_KEY);
51
+ backgroundImage = stored?.startsWith("data:image/") ? stored : undefined;
52
+ }
53
+ catch {
54
+ backgroundImage = undefined;
55
+ }
56
+ return {
57
+ cameraId: text(raw.cameraId),
58
+ microphoneId: text(raw.microphoneId),
59
+ micEnabled: flag(raw.micEnabled),
60
+ cameraEnabled: flag(raw.cameraEnabled),
61
+ filters: raw.filters === undefined ? undefined : sanitizeFilters(raw.filters),
62
+ backgroundImage,
63
+ };
64
+ }
65
+ /** Merges `patch` into what is saved. A key set to `undefined` is forgotten; a failure to save is ignored. */
66
+ export function savePreferences(patch, storage = defaultStorage()) {
67
+ if (!storage) {
68
+ return;
69
+ }
70
+ const { backgroundImage, ...rest } = patch;
71
+ if (Object.keys(rest).length > 0) {
72
+ try {
73
+ const merged = { ...readJson(storage, PREFERENCES_KEY), ...rest };
74
+ storage.setItem(PREFERENCES_KEY, JSON.stringify(merged));
75
+ }
76
+ catch {
77
+ // Nowhere to save - the settings just won't carry over.
78
+ }
79
+ }
80
+ if ("backgroundImage" in patch) {
81
+ try {
82
+ if (backgroundImage) {
83
+ storage.setItem(BACKGROUND_KEY, backgroundImage);
84
+ }
85
+ else {
86
+ storage.removeItem(BACKGROUND_KEY);
87
+ }
88
+ }
89
+ catch {
90
+ // Most likely the image is too big for what is left of the quota.
91
+ }
92
+ }
93
+ }
@@ -1,4 +1,6 @@
1
1
  import { type DeviceLists, type MediaAccessError } from "./deviceMedia.js";
2
+ import { type VideoFilters } from "./filters/filterTypes.js";
3
+ import { type FilterStatus } from "./filters/VideoFilterProcessor.js";
2
4
  export type MediaKind = "audio" | "video";
3
5
  /** `pending`: not asked yet, or being asked. `live`: a track is running. `off`: the participant turned the camera
4
6
  * off. `denied`/`unavailable`/`error`: the browser said no, there is no such device (or it was unplugged), or
@@ -26,6 +28,16 @@ export interface LocalMedia {
26
28
  selectedDeviceIds: Partial<Record<MediaKind, string>>;
27
29
  /** 0-`AUDIO_LEVEL_STEPS`: how loud the microphone is right now - always 0 while muted. */
28
30
  audioLevel: number;
31
+ /** The video filters in effect - `NO_FILTERS` when the camera is sent as it is. */
32
+ filters: VideoFilters;
33
+ /** Whether the filters' models are still loading, and why a filter isn't fully working. */
34
+ filterStatus: FilterStatus;
35
+ /** Whether a custom background image has been chosen (and so "custom image" can be switched to at once). */
36
+ hasBackgroundImage: boolean;
37
+ /** Changes some of the filters, keeping the rest. */
38
+ setFilters(patch: Partial<VideoFilters>): void;
39
+ /** Uses `file` as the custom background and switches to it. Resolves to why the file can't be used, or `null`. */
40
+ chooseBackgroundImage(file: File): Promise<string | null>;
29
41
  /** Asks for the camera and microphone (or just `kind`), see this module's doc comment. */
30
42
  requestAccess(kind?: MediaKind): Promise<void>;
31
43
  toggleMic(): Promise<void>;
@@ -34,4 +46,8 @@ export interface LocalMedia {
34
46
  /** Stops both tracks. */
35
47
  release(): void;
36
48
  }
37
- export declare function useLocalMedia(): LocalMedia;
49
+ export interface LocalMediaOptions {
50
+ /** Where the filters' models are hosted, when not on the default CDN - see `filters/mlModels.ts`. */
51
+ effectsAssetsUrl?: string;
52
+ }
53
+ export declare function useLocalMedia({ effectsAssetsUrl }?: LocalMediaOptions): LocalMedia;