@rapidmx/meet-plugin 0.6.0 → 0.8.1

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 (131) hide show
  1. package/README.md +18 -5
  2. package/apps/meet/[token].tsx +11 -10
  3. package/apps/meet/_CallControls.tsx +28 -2
  4. package/apps/meet/_CallView.tsx +26 -2
  5. package/apps/meet/_EffectsPanel.tsx +134 -0
  6. package/apps/meet/_MeetChrome.tsx +1 -1
  7. package/apps/meet/_MeetLobby.tsx +36 -4
  8. package/apps/meet/_ParticipantTile.tsx +73 -10
  9. package/apps/meet/_icons.tsx +4 -0
  10. package/apps/meet/_layout.tsx +2 -2
  11. package/apps/meet/_meetApi.ts +7 -1
  12. package/apps/meet/index.tsx +2 -2
  13. package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +4 -4
  14. package/apps/settings-video-conferencing/_layout.tsx +2 -2
  15. package/apps/settings-video-conferencing/index.tsx +3 -3
  16. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -0
  17. package/apps/shared/media/filters/accessories.ts +262 -0
  18. package/apps/shared/media/filters/backgroundImage.ts +69 -0
  19. package/apps/shared/media/filters/filterTypes.ts +89 -0
  20. package/apps/shared/media/filters/mlModels.ts +174 -0
  21. package/apps/shared/media/filters/pixelEffects.ts +61 -0
  22. package/apps/shared/media/mediaPreferences.ts +105 -0
  23. package/apps/shared/media/useLocalMedia.ts +198 -14
  24. package/apps/shared/push/GuestSignalingClient.ts +4 -4
  25. package/apps/shared/relay/AudioPlayer.ts +111 -0
  26. package/apps/shared/relay/AudioSender.ts +194 -0
  27. package/apps/shared/relay/RelayClient.ts +262 -0
  28. package/apps/shared/relay/RelayReceiver.ts +72 -0
  29. package/apps/shared/relay/RelaySender.ts +74 -0
  30. package/apps/shared/relay/RelayTransport.ts +188 -0
  31. package/apps/shared/relay/VideoPlayer.ts +110 -0
  32. package/apps/shared/relay/VideoSender.ts +216 -0
  33. package/apps/shared/relay/audioResume.ts +55 -0
  34. package/apps/shared/relay/frames.ts +275 -0
  35. package/apps/shared/relay/relayEnv.ts +230 -0
  36. package/apps/shared/relay/senderCommon.ts +30 -0
  37. package/apps/shared/webrtc/MeshConnectionManager.ts +159 -7
  38. package/apps/shared/webrtc/realPeerConnection.ts +22 -0
  39. package/apps/shared/webrtc/types.ts +32 -2
  40. package/dist/apps/meet/[token].d.ts +3 -3
  41. package/dist/apps/meet/[token].js +10 -10
  42. package/dist/apps/meet/_CallControls.d.ts +2 -1
  43. package/dist/apps/meet/_CallControls.js +6 -3
  44. package/dist/apps/meet/_CallView.d.ts +4 -1
  45. package/dist/apps/meet/_CallView.js +20 -3
  46. package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
  47. package/dist/apps/meet/_EffectsPanel.js +60 -0
  48. package/dist/apps/meet/_MeetChrome.d.ts +1 -1
  49. package/dist/apps/meet/_MeetLobby.d.ts +8 -0
  50. package/dist/apps/meet/_MeetLobby.js +15 -4
  51. package/dist/apps/meet/_ParticipantTile.d.ts +17 -2
  52. package/dist/apps/meet/_ParticipantTile.js +27 -4
  53. package/dist/apps/meet/_icons.d.ts +1 -0
  54. package/dist/apps/meet/_icons.js +1 -0
  55. package/dist/apps/meet/_layout.d.ts +1 -1
  56. package/dist/apps/meet/_layout.js +1 -1
  57. package/dist/apps/meet/_meetApi.d.ts +6 -0
  58. package/dist/apps/meet/_meetApi.js +1 -1
  59. package/dist/apps/meet/index.js +2 -2
  60. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +1 -1
  61. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +4 -4
  62. package/dist/apps/settings-video-conferencing/_layout.d.ts +1 -1
  63. package/dist/apps/settings-video-conferencing/_layout.js +1 -1
  64. package/dist/apps/settings-video-conferencing/index.d.ts +1 -1
  65. package/dist/apps/settings-video-conferencing/index.js +3 -3
  66. package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +78 -0
  67. package/dist/apps/shared/media/filters/VideoFilterProcessor.js +329 -0
  68. package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
  69. package/dist/apps/shared/media/filters/accessories.js +211 -0
  70. package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
  71. package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
  72. package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
  73. package/dist/apps/shared/media/filters/filterTypes.js +63 -0
  74. package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
  75. package/dist/apps/shared/media/filters/mlModels.js +106 -0
  76. package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
  77. package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
  78. package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
  79. package/dist/apps/shared/media/mediaPreferences.js +93 -0
  80. package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
  81. package/dist/apps/shared/media/useLocalMedia.js +160 -14
  82. package/dist/apps/shared/push/GuestSignalingClient.d.ts +1 -1
  83. package/dist/apps/shared/push/GuestSignalingClient.js +3 -3
  84. package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
  85. package/dist/apps/shared/relay/AudioPlayer.js +103 -0
  86. package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
  87. package/dist/apps/shared/relay/AudioSender.js +172 -0
  88. package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
  89. package/dist/apps/shared/relay/RelayClient.js +236 -0
  90. package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
  91. package/dist/apps/shared/relay/RelayReceiver.js +59 -0
  92. package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
  93. package/dist/apps/shared/relay/RelaySender.js +54 -0
  94. package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
  95. package/dist/apps/shared/relay/RelayTransport.js +142 -0
  96. package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
  97. package/dist/apps/shared/relay/VideoPlayer.js +102 -0
  98. package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
  99. package/dist/apps/shared/relay/VideoSender.js +204 -0
  100. package/dist/apps/shared/relay/audioResume.d.ts +12 -0
  101. package/dist/apps/shared/relay/audioResume.js +45 -0
  102. package/dist/apps/shared/relay/frames.d.ts +101 -0
  103. package/dist/apps/shared/relay/frames.js +221 -0
  104. package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
  105. package/dist/apps/shared/relay/relayEnv.js +69 -0
  106. package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
  107. package/dist/apps/shared/relay/senderCommon.js +10 -0
  108. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +52 -2
  109. package/dist/apps/shared/webrtc/MeshConnectionManager.js +138 -7
  110. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  111. package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
  112. package/dist/apps/shared/webrtc/types.d.ts +29 -2
  113. package/dist/lib/index.js +2 -0
  114. package/dist/lib/index.js.map +1 -1
  115. package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
  116. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  117. package/dist/lib/util/EffectsUtils.js +21 -0
  118. package/dist/lib/util/EffectsUtils.js.map +1 -0
  119. package/dist/lib/util/IceServerUtils.js +25 -4
  120. package/dist/lib/util/IceServerUtils.js.map +1 -1
  121. package/dist/lib/util/RedisRelayBus.js +241 -0
  122. package/dist/lib/util/RedisRelayBus.js.map +1 -0
  123. package/dist/lib/util/RelayHub.js +405 -0
  124. package/dist/lib/util/RelayHub.js.map +1 -0
  125. package/dist/types/index.d.ts +2 -0
  126. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
  127. package/dist/types/util/EffectsUtils.d.ts +10 -0
  128. package/dist/types/util/IceServerUtils.d.ts +12 -1
  129. package/dist/types/util/RedisRelayBus.d.ts +131 -0
  130. package/dist/types/util/RelayHub.d.ts +188 -0
  131. package/package.json +25 -11
@@ -0,0 +1,38 @@
1
+ import { type RelayEnv } from "./relayEnv.js";
2
+ export interface RelayTransport {
3
+ /** false when this browser lacks WebSocket, AudioEncoder/AudioDecoder/VideoEncoder/VideoDecoder, VideoFrame,
4
+ * AudioContext or canvas.captureStream - the mesh then never falls back to the relay. */
5
+ readonly supported: boolean;
6
+ /** Start receiving `peerId`'s media (opens/keeps the socket, adds to `want`). `onStream` gets a MediaStream that
7
+ * plays that peer (one audio + one video track). Called at most once per stream creation; calling receiveFrom
8
+ * again for the same peer is a no-op. */
9
+ receiveFrom(peerId: string, onStream: (stream: MediaStream) => void): void;
10
+ stopReceivingFrom(peerId: string): void;
11
+ /** Start/stop publishing the local tracks (encode + send). Only encodes while active; stopping releases the
12
+ * encoders and audio nodes. */
13
+ setSending(active: boolean): void;
14
+ /** Same semantics as `MeshConnectionManager.setLocalTrack`: null = send nothing for that kind. Safe to call
15
+ * before or after `setSending`. */
16
+ setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
17
+ /** Closes the socket, all coders, streams and timers. Idempotent. */
18
+ close(): void;
19
+ }
20
+ export interface RelayTransportOptions {
21
+ meetingUid: string;
22
+ /** This participant's peer id on the meeting's signaling channel - the id the server stamps on what it sends. */
23
+ peerId: string;
24
+ /** Overrides the socket URL (default: `relayUrl(meetingUid)`). */
25
+ url?: string;
26
+ /** Overrides every browser API the relay uses (default: `detectRelayEnv()`), so a test can drive it with fakes. */
27
+ env?: RelayEnv;
28
+ }
29
+ /** The relay WebSocket's URL for a meeting: this deployment's origin (the API origin when the UI is served from a
30
+ * different one, else the page's own), `http` becoming `ws` - the same derivation `pushUrl()` uses for `/push`.
31
+ * `undefined` when there is no origin at all (not running in a browser). */
32
+ export declare function relayUrl(meetingUid: string): string | undefined;
33
+ /**
34
+ * Creates the relay transport for one meeting. Returns an inert transport (`supported: false`) when the browser lacks
35
+ * something the relay needs, so the caller can simply check `supported` once and never fall back to it.
36
+ * No socket is opened until the first `receiveFrom()` or `setSending(true)`.
37
+ */
38
+ export declare function createRelayTransport(options: RelayTransportOptions): RelayTransport;
@@ -0,0 +1,142 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The last-resort media tier of a call: when two participants' WebRTC connection cannot be made at all (typically a
7
+ * network that blocks UDP and TURN alike), their audio and video travel over a WebSocket to the server, which fans
8
+ * each participant's frames out to whoever asked for them. This module is the facade the mesh manager talks to; the
9
+ * pieces behind it are:
10
+ *
11
+ * - `RelayClient` - the socket: hello/ready/want, reconnection, dropping media it cannot send promptly.
12
+ * - `frames` - the fragment format inside each WebSocket message and the reassembly of it.
13
+ * - `RelaySender` (`AudioSender`, `VideoSender`) - capture and encode the local tracks (Opus and VP8 via WebCodecs).
14
+ * - `RelayReceiver` (`AudioPlayer`, `VideoPlayer`) - decode a remote participant back into a `MediaStream`.
15
+ *
16
+ * There is one transport (one socket) per meeting, however many peers use it, and the codecs are fixed - there is no
17
+ * negotiation, because both ends are this same code.
18
+ */
19
+ import { apiOrigin } from "@rapidmx/web-client/lib/util/api.js";
20
+ import { PLAYBACK_SAMPLE_RATE } from "./AudioPlayer.js";
21
+ import { resumeAudioContext } from "./audioResume.js";
22
+ import { RelayClient } from "./RelayClient.js";
23
+ import { detectRelayEnv } from "./relayEnv.js";
24
+ import { RelayReceiver } from "./RelayReceiver.js";
25
+ import { RelaySender } from "./RelaySender.js";
26
+ /** The relay WebSocket's URL for a meeting: this deployment's origin (the API origin when the UI is served from a
27
+ * different one, else the page's own), `http` becoming `ws` - the same derivation `pushUrl()` uses for `/push`.
28
+ * `undefined` when there is no origin at all (not running in a browser). */
29
+ export function relayUrl(meetingUid) {
30
+ const origin = apiOrigin() || (typeof window !== "undefined" ? window.location.origin : "");
31
+ return origin ? `${origin.replace(/^http/i, "ws")}/api/mail/video-meetings/relay/${encodeURIComponent(meetingUid)}` : undefined;
32
+ }
33
+ /** What `createRelayTransport()` returns on a browser that cannot run the relay: every call is a no-op. */
34
+ const UNSUPPORTED_TRANSPORT = {
35
+ supported: false,
36
+ receiveFrom: () => undefined,
37
+ stopReceivingFrom: () => undefined,
38
+ setSending: () => undefined,
39
+ setLocalTrack: () => undefined,
40
+ close: () => undefined,
41
+ };
42
+ class WebSocketRelayTransport {
43
+ constructor(env, url, peerId) {
44
+ this.env = env;
45
+ this.supported = true;
46
+ this.receivers = new Map();
47
+ this.closed = false;
48
+ this.client = new RelayClient({
49
+ url,
50
+ peerId,
51
+ createSocket: (target) => env.createSocket(target),
52
+ random: () => env.random(),
53
+ onMedia: (sender, payload) => this.receivers.get(sender)?.handleMedia(payload),
54
+ });
55
+ this.sender = new RelaySender({
56
+ env,
57
+ send: (message) => this.client.sendMedia(message),
58
+ canSend: () => this.client.ready,
59
+ maxMessageBytes: () => this.client.maxMessageBytes,
60
+ });
61
+ }
62
+ receiveFrom(peerId, onStream) {
63
+ if (this.closed || this.receivers.has(peerId)) {
64
+ return;
65
+ }
66
+ const receiver = this.createReceiver();
67
+ if (!receiver) {
68
+ return;
69
+ }
70
+ this.receivers.set(peerId, receiver);
71
+ this.client.start();
72
+ this.client.setWanted(this.receivers.keys());
73
+ onStream(receiver.stream);
74
+ }
75
+ stopReceivingFrom(peerId) {
76
+ const receiver = this.receivers.get(peerId);
77
+ if (!receiver) {
78
+ return;
79
+ }
80
+ this.receivers.delete(peerId);
81
+ receiver.close();
82
+ this.client.setWanted(this.receivers.keys());
83
+ }
84
+ setSending(active) {
85
+ if (this.closed) {
86
+ return;
87
+ }
88
+ if (active) {
89
+ this.client.start();
90
+ }
91
+ this.sender.setActive(active);
92
+ }
93
+ setLocalTrack(kind, track) {
94
+ if (!this.closed) {
95
+ this.sender.setTrack(kind, track);
96
+ }
97
+ }
98
+ close() {
99
+ if (this.closed) {
100
+ return;
101
+ }
102
+ this.closed = true;
103
+ this.sender.setActive(false);
104
+ for (const receiver of this.receivers.values()) {
105
+ receiver.close();
106
+ }
107
+ this.receivers.clear();
108
+ this.client.close();
109
+ this.stopResume?.();
110
+ // `close()` rejects for a context that is already closed; nothing to do about it.
111
+ this.playback?.close().catch(() => undefined);
112
+ this.playback = undefined;
113
+ }
114
+ /** A receiver on the shared playback context, or `undefined` if this browser will not build one (the peer then
115
+ * simply has no relayed media). */
116
+ createReceiver() {
117
+ try {
118
+ if (!this.playback) {
119
+ this.playback = this.env.createAudioContext(PLAYBACK_SAMPLE_RATE);
120
+ // Nothing the user just did created this context, so it may start suspended - see `resumeAudioContext`.
121
+ this.stopResume = resumeAudioContext(this.playback, this.env.document);
122
+ }
123
+ return new RelayReceiver(this.env, this.playback);
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ }
130
+ /**
131
+ * Creates the relay transport for one meeting. Returns an inert transport (`supported: false`) when the browser lacks
132
+ * something the relay needs, so the caller can simply check `supported` once and never fall back to it.
133
+ * No socket is opened until the first `receiveFrom()` or `setSending(true)`.
134
+ */
135
+ export function createRelayTransport(options) {
136
+ const env = options.env ?? detectRelayEnv();
137
+ const url = options.url ?? relayUrl(options.meetingUid);
138
+ if (!env || !url) {
139
+ return UNSUPPORTED_TRANSPORT;
140
+ }
141
+ return new WebSocketRelayTransport(env, url, options.peerId);
142
+ }
@@ -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
+ }