@rapidmx/meet-plugin 0.15.0 → 0.16.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.
- package/README.md +35 -55
- package/apps/meet/[token].tsx +341 -341
- package/apps/meet/_CallControls.tsx +508 -483
- package/apps/meet/_CallView.tsx +1060 -924
- package/apps/meet/_ChatPanel.tsx +194 -0
- package/apps/meet/_ColorWheel.tsx +136 -0
- package/apps/meet/_MeetLobby.tsx +224 -224
- package/apps/meet/_ParticipantTile.tsx +243 -242
- package/apps/meet/_Whiteboard.tsx +577 -0
- package/apps/meet/_icons.tsx +135 -90
- package/apps/meet/_meetApi.ts +213 -212
- package/apps/shared/collab/CollabSession.ts +675 -0
- package/apps/shared/collab/WhiteboardModel.ts +216 -0
- package/apps/shared/collab/chatMarkdown.tsx +375 -0
- package/apps/shared/collab/color.ts +67 -0
- package/apps/shared/collab/protocol.ts +321 -0
- package/apps/shared/collab/whiteboardRender.ts +311 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
- package/apps/shared/media/filters/mlModels.ts +174 -174
- package/apps/shared/relay/RelayClient.ts +2 -1
- package/apps/shared/relay/RelayTransport.ts +2 -1
- package/apps/shared/relay/relayEnv.ts +1 -1
- package/apps/shared/signaling/SignalingClient.ts +335 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +971 -971
- package/apps/shared/webrtc/types.ts +335 -316
- package/dist/apps/meet/[token].js +1 -1
- package/dist/apps/meet/_CallControls.d.ts +12 -4
- package/dist/apps/meet/_CallControls.js +5 -5
- package/dist/apps/meet/_CallView.d.ts +19 -4
- package/dist/apps/meet/_CallView.js +100 -11
- package/dist/apps/meet/_ChatPanel.d.ts +24 -0
- package/dist/apps/meet/_ChatPanel.js +116 -0
- package/dist/apps/meet/_ColorWheel.d.ts +14 -0
- package/dist/apps/meet/_ColorWheel.js +82 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +2 -1
- package/dist/apps/meet/_ParticipantTile.js +1 -1
- package/dist/apps/meet/_Whiteboard.d.ts +64 -0
- package/dist/apps/meet/_Whiteboard.js +312 -0
- package/dist/apps/meet/_icons.d.ts +12 -0
- package/dist/apps/meet/_icons.js +12 -0
- package/dist/apps/meet/_meetApi.d.ts +8 -7
- package/dist/apps/meet/_meetApi.js +2 -2
- package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
- package/dist/apps/shared/collab/CollabSession.js +559 -0
- package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
- package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
- package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
- package/dist/apps/shared/collab/chatMarkdown.js +311 -0
- package/dist/apps/shared/collab/color.d.ts +18 -0
- package/dist/apps/shared/collab/color.js +59 -0
- package/dist/apps/shared/collab/protocol.d.ts +157 -0
- package/dist/apps/shared/collab/protocol.js +222 -0
- package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
- package/dist/apps/shared/collab/whiteboardRender.js +268 -0
- package/dist/apps/shared/relay/RelayClient.js +2 -1
- package/dist/apps/shared/relay/RelayTransport.d.ts +2 -1
- package/dist/apps/shared/relay/RelayTransport.js +2 -1
- package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
- package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
- package/dist/apps/shared/signaling/SignalingClient.js +278 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +8 -8
- package/dist/apps/shared/webrtc/types.d.ts +38 -24
- package/dist/apps/shared/webrtc/types.js +3 -4
- package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/SignalingHub.js +339 -0
- package/dist/lib/util/SignalingHub.js.map +1 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
- package/dist/types/util/SignalingHub.d.ts +112 -0
- package/package.json +208 -208
- package/apps/shared/push/GuestSignalingClient.ts +0 -359
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
- package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
|
@@ -1,174 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* - Binary messages may be dropped under backpressure and the socket may close at any moment, so this client
|
|
24
24
|
* reconnects with exponential backoff and treats every media send as best effort.
|
|
25
25
|
*
|
|
26
|
-
* Authentication is the `jwt` cookie already in place (see `
|
|
26
|
+
* Authentication is the `jwt` cookie already in place (see `../signaling/SignalingClient.ts`), so this client sets no
|
|
27
|
+
* cookie.
|
|
27
28
|
*/
|
|
28
29
|
import { DEFAULT_MESSAGE_BYTES } from "./frames.js";
|
|
29
30
|
import type { RelaySocket } from "./relayEnv.js";
|
|
@@ -58,7 +58,8 @@ export interface RelayTransportOptions {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/** The relay WebSocket's URL for a meeting: this deployment's origin (the API origin when the UI is served from a
|
|
61
|
-
* different one, else the page's own), `http` becoming `ws` - the same derivation `
|
|
61
|
+
* different one, else the page's own), `http` becoming `ws` - the same derivation `signalingUrl()` uses for the
|
|
62
|
+
* signaling socket.
|
|
62
63
|
* `undefined` when there is no origin at all (not running in a browser). */
|
|
63
64
|
export function relayUrl(meetingUid: string): string | undefined {
|
|
64
65
|
const origin = apiOrigin() || (typeof window !== "undefined" ? window.location.origin : "");
|
|
@@ -134,7 +134,7 @@ export interface VideoElementLike {
|
|
|
134
134
|
pause(): void;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/** The subset of `WebSocket` the relay client uses (the same seam shape `
|
|
137
|
+
/** The subset of `WebSocket` the relay client uses (the same seam shape `SignalingClient`'s `SignalingSocket` has). */
|
|
138
138
|
export interface RelaySocket {
|
|
139
139
|
readyState: number;
|
|
140
140
|
/** Bytes queued by `send()` that the browser has not yet handed to the network - the backpressure signal. */
|