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