@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,31 @@
1
+ import { type Frame } from "./frames.js";
2
+ import type { CanvasContextLike, CanvasLike, RelayEnv } from "./relayEnv.js";
3
+ /** Skip a frame (and wait for the next key frame) when the decoder is this far behind. */
4
+ export declare const VIDEO_MAX_DECODE_QUEUE = 8;
5
+ /**
6
+ * Decodes one sender's VP8 frames and draws them onto a canvas, whose `captureStream()` track is what the rest of
7
+ * the app plays as that participant's video.
8
+ *
9
+ * VP8 is a chain of deltas hanging off key frames, so a decoder fed a delta frame without its predecessor produces
10
+ * garbage or errors. This therefore decodes only from a key frame onward and falls back to waiting for the next one
11
+ * whenever the chain may be broken: a gap in `seq` (a fragment or a whole frame was dropped on the way), the decoder
12
+ * being too far behind, or any decode error. The sender forces a key frame every couple of seconds, which bounds the
13
+ * wait. The decoder is created lazily and discarded on error.
14
+ */
15
+ export declare class VideoPlayer {
16
+ private readonly env;
17
+ private readonly canvas;
18
+ private readonly context;
19
+ private decoder;
20
+ private needKey;
21
+ private expectedSeq;
22
+ private readonly timestamps;
23
+ constructor(env: RelayEnv, canvas: CanvasLike, context: CanvasContextLike);
24
+ /** Feeds one reassembled video frame. */
25
+ push(frame: Frame): void;
26
+ close(): void;
27
+ private discardDecoder;
28
+ private createDecoder;
29
+ /** Draws one decoded picture, resizing the canvas to it. Always closes `picture`. */
30
+ private draw;
31
+ }
@@ -0,0 +1,102 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { TimestampUnwrapper } from "./frames.js";
6
+ import { safeClose } from "./senderCommon.js";
7
+ /** Skip a frame (and wait for the next key frame) when the decoder is this far behind. */
8
+ export const VIDEO_MAX_DECODE_QUEUE = 8;
9
+ const SEQ_MODULUS = 0x10000;
10
+ /**
11
+ * Decodes one sender's VP8 frames and draws them onto a canvas, whose `captureStream()` track is what the rest of
12
+ * the app plays as that participant's video.
13
+ *
14
+ * VP8 is a chain of deltas hanging off key frames, so a decoder fed a delta frame without its predecessor produces
15
+ * garbage or errors. This therefore decodes only from a key frame onward and falls back to waiting for the next one
16
+ * whenever the chain may be broken: a gap in `seq` (a fragment or a whole frame was dropped on the way), the decoder
17
+ * being too far behind, or any decode error. The sender forces a key frame every couple of seconds, which bounds the
18
+ * wait. The decoder is created lazily and discarded on error.
19
+ */
20
+ export class VideoPlayer {
21
+ constructor(env, canvas, context) {
22
+ this.env = env;
23
+ this.canvas = canvas;
24
+ this.context = context;
25
+ this.needKey = true;
26
+ this.timestamps = new TimestampUnwrapper();
27
+ }
28
+ /** Feeds one reassembled video frame. */
29
+ push(frame) {
30
+ if (this.expectedSeq !== undefined && frame.seq !== this.expectedSeq) {
31
+ this.needKey = true;
32
+ }
33
+ this.expectedSeq = (frame.seq + 1) % SEQ_MODULUS;
34
+ if (frame.keyFrame) {
35
+ this.needKey = false;
36
+ }
37
+ else if (this.needKey) {
38
+ return;
39
+ }
40
+ const decoder = this.decoder ?? this.createDecoder();
41
+ if (!decoder || decoder.decodeQueueSize > VIDEO_MAX_DECODE_QUEUE) {
42
+ this.needKey = true;
43
+ return;
44
+ }
45
+ try {
46
+ decoder.decode(this.env.createEncodedVideoChunk({
47
+ type: frame.keyFrame ? "key" : "delta",
48
+ timestamp: this.timestamps.next(frame.timestampMs) * 1000,
49
+ data: frame.data,
50
+ }));
51
+ }
52
+ catch {
53
+ this.discardDecoder();
54
+ }
55
+ }
56
+ close() {
57
+ this.discardDecoder();
58
+ }
59
+ discardDecoder() {
60
+ const decoder = this.decoder;
61
+ this.decoder = undefined;
62
+ this.needKey = true;
63
+ safeClose(decoder);
64
+ }
65
+ createDecoder() {
66
+ let decoder;
67
+ try {
68
+ decoder = this.env.createVideoDecoder({
69
+ output: (picture) => this.draw(picture),
70
+ error: () => {
71
+ // Only the current decoder's failure matters; a stale one was already replaced.
72
+ if (this.decoder === decoder) {
73
+ this.discardDecoder();
74
+ }
75
+ },
76
+ });
77
+ decoder.configure({ codec: "vp8" });
78
+ }
79
+ catch {
80
+ safeClose(decoder);
81
+ return undefined;
82
+ }
83
+ this.decoder = decoder;
84
+ return decoder;
85
+ }
86
+ /** Draws one decoded picture, resizing the canvas to it. Always closes `picture`. */
87
+ draw(picture) {
88
+ try {
89
+ if (this.canvas.width !== picture.displayWidth || this.canvas.height !== picture.displayHeight) {
90
+ this.canvas.width = picture.displayWidth;
91
+ this.canvas.height = picture.displayHeight;
92
+ }
93
+ this.context.drawImage(picture, 0, 0);
94
+ }
95
+ catch {
96
+ // A picture the canvas will not take is dropped; the next one draws over it anyway.
97
+ }
98
+ finally {
99
+ picture.close();
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,56 @@
1
+ import { type SenderDeps } from "./senderCommon.js";
2
+ export declare const VIDEO_MAX_WIDTH = 480;
3
+ export declare const VIDEO_MAX_HEIGHT = 360;
4
+ export declare const VIDEO_FPS = 15;
5
+ export declare const VIDEO_BITRATE = 350000;
6
+ /** A key frame is forced this often, so a receiver that joins or loses a frame recovers within two seconds. */
7
+ export declare const VIDEO_KEY_FRAME_INTERVAL = 30;
8
+ /** Skip a tick instead of queueing a frame when the encoder is this far behind. */
9
+ export declare const VIDEO_MAX_ENCODE_QUEUE = 3;
10
+ /** The size a `width` x `height` picture is encoded at: shrunk to fit `VIDEO_MAX_WIDTH` x `VIDEO_MAX_HEIGHT` keeping
11
+ * its aspect ratio (never enlarged), and rounded to even numbers because VP8's chroma planes are half size. */
12
+ export declare function fitSize(width: number, height: number): {
13
+ width: number;
14
+ height: number;
15
+ };
16
+ /**
17
+ * Captures one camera or screen-share track and publishes it as VP8.
18
+ *
19
+ * One capture path: a hidden `<video>` plays the track, a `setInterval` at about 15 fps draws its current picture onto
20
+ * a canvas (which also does the down-scaling, so the encoder always sees the size it was configured for), and the
21
+ * canvas becomes a `VideoFrame` for the `VideoEncoder`. A key frame is forced at the start, after any change of
22
+ * source or size and every `VIDEO_KEY_FRAME_INTERVAL` frames, and whenever a frame could not be sent (a receiver
23
+ * that missed a fragment cannot decode the deltas that follow it, so the fastest cure is a new key frame).
24
+ *
25
+ * Replacing the track (a screen share swaps the camera track for a display track) keeps the loop running: the
26
+ * `<video>` is pointed at the new track and a key frame is forced. The encoder is created lazily at the first real
27
+ * picture, because its size is only known once the video has one, and discarded on any error rather than repaired
28
+ * inside its own error callback.
29
+ */
30
+ export declare class VideoSender {
31
+ private readonly deps;
32
+ private active;
33
+ private track;
34
+ private attachedTrack;
35
+ private video;
36
+ private timer;
37
+ private encoder;
38
+ private encodedWidth;
39
+ private encodedHeight;
40
+ private frameCount;
41
+ private forceKey;
42
+ private baseMs;
43
+ constructor(deps: SenderDeps);
44
+ setActive(active: boolean): void;
45
+ setTrack(track: MediaStreamTrack | null): void;
46
+ private sync;
47
+ private start;
48
+ private attachSource;
49
+ private stop;
50
+ private discardEncoder;
51
+ private tick;
52
+ /** (Re)creates the encoder for a `width` x `height` picture. VP8 cannot change size without a key frame, so this
53
+ * always forces one. Returns `undefined` when this browser cannot make the encoder. */
54
+ private configureEncoder;
55
+ private onChunk;
56
+ }
@@ -0,0 +1,204 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import { KIND_VIDEO } from "./frames.js";
6
+ import { safeClose } from "./senderCommon.js";
7
+ export const VIDEO_MAX_WIDTH = 480;
8
+ export const VIDEO_MAX_HEIGHT = 360;
9
+ export const VIDEO_FPS = 15;
10
+ export const VIDEO_BITRATE = 350000;
11
+ /** A key frame is forced this often, so a receiver that joins or loses a frame recovers within two seconds. */
12
+ export const VIDEO_KEY_FRAME_INTERVAL = 30;
13
+ /** Skip a tick instead of queueing a frame when the encoder is this far behind. */
14
+ export const VIDEO_MAX_ENCODE_QUEUE = 3;
15
+ /** `HTMLMediaElement.HAVE_CURRENT_DATA`: a frame is available to draw. */
16
+ const HAVE_CURRENT_DATA = 2;
17
+ /** The size a `width` x `height` picture is encoded at: shrunk to fit `VIDEO_MAX_WIDTH` x `VIDEO_MAX_HEIGHT` keeping
18
+ * its aspect ratio (never enlarged), and rounded to even numbers because VP8's chroma planes are half size. */
19
+ export function fitSize(width, height) {
20
+ const scale = Math.min(1, VIDEO_MAX_WIDTH / width, VIDEO_MAX_HEIGHT / height);
21
+ return {
22
+ width: Math.max(2, 2 * Math.round((width * scale) / 2)),
23
+ height: Math.max(2, 2 * Math.round((height * scale) / 2)),
24
+ };
25
+ }
26
+ /**
27
+ * Captures one camera or screen-share track and publishes it as VP8.
28
+ *
29
+ * One capture path: a hidden `<video>` plays the track, a `setInterval` at about 15 fps draws its current picture onto
30
+ * a canvas (which also does the down-scaling, so the encoder always sees the size it was configured for), and the
31
+ * canvas becomes a `VideoFrame` for the `VideoEncoder`. A key frame is forced at the start, after any change of
32
+ * source or size and every `VIDEO_KEY_FRAME_INTERVAL` frames, and whenever a frame could not be sent (a receiver
33
+ * that missed a fragment cannot decode the deltas that follow it, so the fastest cure is a new key frame).
34
+ *
35
+ * Replacing the track (a screen share swaps the camera track for a display track) keeps the loop running: the
36
+ * `<video>` is pointed at the new track and a key frame is forced. The encoder is created lazily at the first real
37
+ * picture, because its size is only known once the video has one, and discarded on any error rather than repaired
38
+ * inside its own error callback.
39
+ */
40
+ export class VideoSender {
41
+ constructor(deps) {
42
+ this.deps = deps;
43
+ this.active = false;
44
+ this.track = null;
45
+ this.attachedTrack = null;
46
+ this.encodedWidth = 0;
47
+ this.encodedHeight = 0;
48
+ this.frameCount = 0;
49
+ this.forceKey = true;
50
+ this.baseMs = 0;
51
+ }
52
+ setActive(active) {
53
+ this.active = active;
54
+ this.sync();
55
+ }
56
+ setTrack(track) {
57
+ this.track = track;
58
+ this.sync();
59
+ }
60
+ sync() {
61
+ const wanted = this.active ? this.track : null;
62
+ if (wanted === this.attachedTrack) {
63
+ return;
64
+ }
65
+ if (!wanted) {
66
+ this.stop();
67
+ }
68
+ else if (this.video) {
69
+ this.attachedTrack = wanted;
70
+ this.attachSource(this.video, wanted);
71
+ this.forceKey = true;
72
+ }
73
+ else {
74
+ this.start(wanted);
75
+ }
76
+ }
77
+ start(track) {
78
+ const env = this.deps.env;
79
+ this.attachedTrack = track;
80
+ this.baseMs = env.now();
81
+ this.frameCount = 0;
82
+ this.forceKey = true;
83
+ try {
84
+ const video = env.createVideoElement();
85
+ video.muted = true;
86
+ video.autoplay = true;
87
+ video.playsInline = true;
88
+ const canvas = env.createCanvas();
89
+ const context = canvas.getContext("2d");
90
+ if (!context) {
91
+ throw new Error("No 2D canvas context.");
92
+ }
93
+ this.video = video;
94
+ this.attachSource(video, track);
95
+ this.timer = env.setInterval(() => this.tick(video, canvas, context), Math.round(1000 / VIDEO_FPS));
96
+ }
97
+ catch {
98
+ // No usable video element or canvas: video simply isn't relayed.
99
+ this.stop();
100
+ }
101
+ }
102
+ attachSource(video, track) {
103
+ video.srcObject = this.deps.env.createMediaStream([track]);
104
+ // Autoplay policy can reject this; a muted, inline video is normally allowed, and a rejection just means no
105
+ // pictures (readyState never advances), which `tick()` already treats as "nothing to send".
106
+ video.play()?.catch(() => undefined);
107
+ }
108
+ stop() {
109
+ this.attachedTrack = null;
110
+ if (this.timer !== undefined) {
111
+ this.deps.env.clearInterval(this.timer);
112
+ this.timer = undefined;
113
+ }
114
+ if (this.video) {
115
+ this.video.pause();
116
+ this.video.srcObject = null;
117
+ this.video = undefined;
118
+ }
119
+ this.discardEncoder();
120
+ }
121
+ discardEncoder() {
122
+ const encoder = this.encoder;
123
+ this.encoder = undefined;
124
+ this.encodedWidth = this.encodedHeight = 0;
125
+ safeClose(encoder);
126
+ }
127
+ tick(video, canvas, context) {
128
+ if (!this.deps.canSend()) {
129
+ // Nothing is being sent, so whoever starts receiving next needs a key frame to begin with.
130
+ this.forceKey = true;
131
+ return;
132
+ }
133
+ if (video.readyState < HAVE_CURRENT_DATA || video.videoWidth === 0 || video.videoHeight === 0) {
134
+ return;
135
+ }
136
+ const { width, height } = fitSize(video.videoWidth, video.videoHeight);
137
+ const encoder = this.encoder && width === this.encodedWidth && height === this.encodedHeight ? this.encoder : this.configureEncoder(width, height);
138
+ if (!encoder || encoder.encodeQueueSize > VIDEO_MAX_ENCODE_QUEUE) {
139
+ return;
140
+ }
141
+ if (canvas.width !== width || canvas.height !== height) {
142
+ canvas.width = width;
143
+ canvas.height = height;
144
+ }
145
+ context.drawImage(video, 0, 0, width, height);
146
+ const keyFrame = this.forceKey || this.frameCount % VIDEO_KEY_FRAME_INTERVAL === 0;
147
+ let frame;
148
+ try {
149
+ frame = this.deps.env.createVideoFrame(canvas, Math.round((this.deps.env.now() - this.baseMs) * 1000));
150
+ encoder.encode(frame, { keyFrame });
151
+ this.forceKey = false;
152
+ this.frameCount += 1;
153
+ }
154
+ catch {
155
+ this.discardEncoder();
156
+ this.forceKey = true;
157
+ }
158
+ finally {
159
+ frame?.close();
160
+ }
161
+ }
162
+ /** (Re)creates the encoder for a `width` x `height` picture. VP8 cannot change size without a key frame, so this
163
+ * always forces one. Returns `undefined` when this browser cannot make the encoder. */
164
+ configureEncoder(width, height) {
165
+ this.discardEncoder();
166
+ let encoder;
167
+ try {
168
+ encoder = this.deps.env.createVideoEncoder({
169
+ output: (chunk) => this.onChunk(chunk),
170
+ error: () => {
171
+ // Only the current encoder's failure matters; a stale one was already replaced.
172
+ if (this.encoder === encoder) {
173
+ this.discardEncoder();
174
+ this.forceKey = true;
175
+ }
176
+ },
177
+ });
178
+ encoder.configure({
179
+ codec: "vp8",
180
+ width,
181
+ height,
182
+ bitrate: VIDEO_BITRATE,
183
+ framerate: VIDEO_FPS,
184
+ latencyMode: "realtime",
185
+ });
186
+ }
187
+ catch {
188
+ safeClose(encoder);
189
+ return undefined;
190
+ }
191
+ this.encoder = encoder;
192
+ this.encodedWidth = width;
193
+ this.encodedHeight = height;
194
+ this.forceKey = true;
195
+ return encoder;
196
+ }
197
+ onChunk(chunk) {
198
+ const bytes = new Uint8Array(chunk.byteLength);
199
+ chunk.copyTo(bytes);
200
+ if (!this.deps.publish(KIND_VIDEO, chunk.type === "key", bytes, this.baseMs + chunk.timestamp / 1000)) {
201
+ this.forceKey = true;
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,12 @@
1
+ import type { AudioContextLike, EventTargetLike } from "./relayEnv.js";
2
+ /**
3
+ * Gets a suspended `AudioContext` running. A context created without a user gesture (the relay creates its
4
+ * playback context when the mesh falls back, which nothing the user just did caused) starts suspended under the
5
+ * browsers' autoplay policies, and stays silent until `resume()` is called from a gesture. So this tries
6
+ * `resume()` immediately - which succeeds when the page already has user activation, the usual case in a call the
7
+ * user clicked "Join" for - and otherwise retries on the next `pointerdown`/`click`/`keydown`, removing the
8
+ * listeners as soon as the context is running (or after that one retry, so an unhelpful browser doesn't keep a
9
+ * listener on `document` forever). Returns a function that removes the listeners early. A no-op when the context
10
+ * is not suspended.
11
+ */
12
+ export declare function resumeAudioContext(ctx: AudioContextLike, target: EventTargetLike): () => void;
@@ -0,0 +1,45 @@
1
+ /** The user gestures a browser accepts as permission to start audio. */
2
+ const GESTURE_EVENTS = ["pointerdown", "click", "keydown"];
3
+ /**
4
+ * Gets a suspended `AudioContext` running. A context created without a user gesture (the relay creates its
5
+ * playback context when the mesh falls back, which nothing the user just did caused) starts suspended under the
6
+ * browsers' autoplay policies, and stays silent until `resume()` is called from a gesture. So this tries
7
+ * `resume()` immediately - which succeeds when the page already has user activation, the usual case in a call the
8
+ * user clicked "Join" for - and otherwise retries on the next `pointerdown`/`click`/`keydown`, removing the
9
+ * listeners as soon as the context is running (or after that one retry, so an unhelpful browser doesn't keep a
10
+ * listener on `document` forever). Returns a function that removes the listeners early. A no-op when the context
11
+ * is not suspended.
12
+ */
13
+ export function resumeAudioContext(ctx, target) {
14
+ if (ctx.state !== "suspended") {
15
+ return () => undefined;
16
+ }
17
+ let disposed = false;
18
+ const dispose = () => {
19
+ if (disposed) {
20
+ return;
21
+ }
22
+ disposed = true;
23
+ for (const type of GESTURE_EVENTS) {
24
+ target.removeEventListener(type, onGesture, true);
25
+ }
26
+ };
27
+ const tryResume = () => {
28
+ // `resume()` returns a promise that stays pending until the browser allows it (and can reject); neither is
29
+ // something the caller can act on, and the next gesture is the retry.
30
+ ctx.resume().then(() => {
31
+ if (ctx.state === "running") {
32
+ dispose();
33
+ }
34
+ }, () => undefined);
35
+ };
36
+ function onGesture() {
37
+ tryResume();
38
+ dispose();
39
+ }
40
+ for (const type of GESTURE_EVENTS) {
41
+ target.addEventListener(type, onGesture, true);
42
+ }
43
+ tryResume();
44
+ return dispose;
45
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The media frame format carried inside a relay WebSocket message - see `RelayClient.ts` for the envelope the
3
+ * server wraps around it. The server treats the payload as opaque bytes, so everything in this module is a contract
4
+ * between two browsers running this same code, and it has to survive an unreliable transport: a WebSocket to the
5
+ * relay is ordered and lossless per connection, but the server drops binary messages under backpressure, so a
6
+ * receiver must cope with missing fragments without throwing or growing its memory without bound.
7
+ *
8
+ * ## Layout (all big-endian)
9
+ *
10
+ * A frame (one encoded audio packet or one encoded video picture) is split into fragments sized to the largest
11
+ * WebSocket message the server accepts (`fragmentPayloadSize()`): an older server allows only 16384 bytes (a larger
12
+ * message closes the socket) and a key frame easily runs to tens of kilobytes, while a current one announces a
13
+ * larger limit in its `ready` reply. Each fragment is
14
+ *
15
+ * `[kind u8][flags u8][seq u16][fragIndex u8][fragCount u8][timestampMs u32][fragment bytes]`
16
+ *
17
+ * `seq` is a per-kind frame counter that wraps at 65536, so a receiver can tell fragments of different frames
18
+ * apart and notice a gap. `timestampMs` is the sender's monotonic clock and also wraps (at about 49.7 days), which
19
+ * is why the receiver runs it through `TimestampUnwrapper` before handing it to a decoder.
20
+ */
21
+ export declare const KIND_AUDIO = 1;
22
+ export declare const KIND_VIDEO = 2;
23
+ export type FrameKind = typeof KIND_AUDIO | typeof KIND_VIDEO;
24
+ /** Bit 0 of the flags byte: this frame is independently decodable (always set for audio packets). */
25
+ export declare const FLAG_KEY_FRAME = 1;
26
+ export declare const HEADER_BYTES = 10;
27
+ /** The largest client-to-server message an older server accepts, and what a client assumes until the server's
28
+ * `ready` says otherwise. */
29
+ export declare const DEFAULT_MESSAGE_BYTES = 16384;
30
+ /** The payload size used under the default limit. It is well below 16384 - 10 so an older server keeps behaving
31
+ * exactly as it always has, with headroom besides. */
32
+ export declare const DEFAULT_FRAGMENT_BYTES = 12000;
33
+ /** The ceiling on the payload of one fragment, however large a message the server accepts: bigger messages gain
34
+ * nothing for a 480x360 stream and make one lost message cost more. */
35
+ export declare const MAX_FRAGMENT_BYTES = 60000;
36
+ /** The most fragments a frame may have (`fragCount` is one byte). */
37
+ export declare const MAX_FRAGMENTS = 255;
38
+ /** The largest frame a receiver will buffer. A 480x360 VP8 key frame at the rates the sender uses is a small
39
+ * fraction of this; the cap exists so a hostile or buggy peer cannot make a receiver allocate without bound. */
40
+ export declare const MAX_FRAME_BYTES: number;
41
+ /** One decoded fragment header plus a view of its bytes. */
42
+ export interface Fragment {
43
+ kind: FrameKind;
44
+ keyFrame: boolean;
45
+ seq: number;
46
+ fragIndex: number;
47
+ fragCount: number;
48
+ timestampMs: number;
49
+ data: Uint8Array;
50
+ }
51
+ /** A completely reassembled frame. */
52
+ export interface Frame {
53
+ kind: FrameKind;
54
+ keyFrame: boolean;
55
+ seq: number;
56
+ timestampMs: number;
57
+ data: Uint8Array;
58
+ }
59
+ /** The payload bytes per fragment for a server that accepts messages of `maxMessageBytes`. At or below the
60
+ * default limit that is `DEFAULT_FRAGMENT_BYTES` (or less, for a smaller limit); above it, everything the limit
61
+ * leaves after the header, capped at `MAX_FRAGMENT_BYTES`. A message is therefore never larger than the limit. */
62
+ export declare function fragmentPayloadSize(maxMessageBytes: number): number;
63
+ /** Splits `data` into wire-ready fragments (header included), each at most `maxMessageBytes` long (default: what an
64
+ * older server accepts). An empty frame still produces one (header-only) fragment. Returns an empty array for a frame
65
+ * the receiver would refuse anyway - larger than `MAX_FRAME_BYTES`, or needing more than `MAX_FRAGMENTS` fragments at
66
+ * this message size - so it is not worth sending. `seq` and `timestampMs` are reduced modulo their field widths, so a
67
+ * caller can pass a free-running counter or clock. */
68
+ export declare function fragmentFrame(kind: FrameKind, keyFrame: boolean, seq: number, timestampMs: number, data: Uint8Array, maxMessageBytes?: number): Uint8Array[];
69
+ /** Parses one fragment, or returns `undefined` for anything that is not a well-formed one (too short, unknown
70
+ * kind, a zero fragment count, an index beyond the count). Never throws: the bytes come off the network. */
71
+ export declare function decodeFragment(bytes: Uint8Array): Fragment | undefined;
72
+ /** True when `a` is a later sequence number than `b`, treating the 16 bit counter as circular. */
73
+ export declare function seqIsNewer(a: number, b: number): boolean;
74
+ /**
75
+ * Collects the fragments of one sender's one media kind back into whole frames. It holds at most one frame in
76
+ * flight: the moment a fragment of a newer sequence arrives, an incomplete older frame is abandoned (its missing
77
+ * piece was dropped by the server, and waiting longer would only add latency). Everything is validated and nothing
78
+ * throws, so a garbage or hostile stream can at worst cost the frames it corrupts.
79
+ */
80
+ export declare class FrameReassembler {
81
+ private pending;
82
+ private lastSeq;
83
+ private stale;
84
+ /** Feeds one fragment. Returns the frame if this fragment completed it. */
85
+ push(fragment: Fragment): Frame | undefined;
86
+ /** Drops any frame in flight, e.g. after the consumer reset its decoder. */
87
+ reset(): void;
88
+ private collect;
89
+ private finish;
90
+ }
91
+ /**
92
+ * Turns the wrapping 32 bit millisecond timestamps into a monotonic number a decoder can be given. The signed
93
+ * difference from the previous value is taken modulo 2^32, so it is correct across the wrap point and also for a
94
+ * frame that arrives slightly out of order (a small negative step).
95
+ */
96
+ export declare class TimestampUnwrapper {
97
+ private last;
98
+ private unwrapped;
99
+ /** Returns the unwrapped time in milliseconds relative to the first timestamp seen. */
100
+ next(timestampMs: number): number;
101
+ }