@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.
- package/README.md +18 -5
- package/apps/meet/[token].tsx +11 -10
- package/apps/meet/_CallControls.tsx +28 -2
- package/apps/meet/_CallView.tsx +26 -2
- package/apps/meet/_EffectsPanel.tsx +134 -0
- package/apps/meet/_MeetChrome.tsx +1 -1
- package/apps/meet/_MeetLobby.tsx +36 -4
- package/apps/meet/_ParticipantTile.tsx +73 -10
- package/apps/meet/_icons.tsx +4 -0
- package/apps/meet/_layout.tsx +2 -2
- package/apps/meet/_meetApi.ts +7 -1
- package/apps/meet/index.tsx +2 -2
- package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +4 -4
- package/apps/settings-video-conferencing/_layout.tsx +2 -2
- package/apps/settings-video-conferencing/index.tsx +3 -3
- package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -0
- package/apps/shared/media/filters/accessories.ts +262 -0
- package/apps/shared/media/filters/backgroundImage.ts +69 -0
- package/apps/shared/media/filters/filterTypes.ts +89 -0
- package/apps/shared/media/filters/mlModels.ts +174 -0
- package/apps/shared/media/filters/pixelEffects.ts +61 -0
- package/apps/shared/media/mediaPreferences.ts +105 -0
- package/apps/shared/media/useLocalMedia.ts +198 -14
- package/apps/shared/push/GuestSignalingClient.ts +4 -4
- package/apps/shared/relay/AudioPlayer.ts +111 -0
- package/apps/shared/relay/AudioSender.ts +194 -0
- package/apps/shared/relay/RelayClient.ts +262 -0
- package/apps/shared/relay/RelayReceiver.ts +72 -0
- package/apps/shared/relay/RelaySender.ts +74 -0
- package/apps/shared/relay/RelayTransport.ts +188 -0
- package/apps/shared/relay/VideoPlayer.ts +110 -0
- package/apps/shared/relay/VideoSender.ts +216 -0
- package/apps/shared/relay/audioResume.ts +55 -0
- package/apps/shared/relay/frames.ts +275 -0
- package/apps/shared/relay/relayEnv.ts +230 -0
- package/apps/shared/relay/senderCommon.ts +30 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +159 -7
- package/apps/shared/webrtc/realPeerConnection.ts +22 -0
- package/apps/shared/webrtc/types.ts +32 -2
- package/dist/apps/meet/[token].d.ts +3 -3
- package/dist/apps/meet/[token].js +10 -10
- package/dist/apps/meet/_CallControls.d.ts +2 -1
- package/dist/apps/meet/_CallControls.js +6 -3
- package/dist/apps/meet/_CallView.d.ts +4 -1
- package/dist/apps/meet/_CallView.js +20 -3
- package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
- package/dist/apps/meet/_EffectsPanel.js +60 -0
- package/dist/apps/meet/_MeetChrome.d.ts +1 -1
- package/dist/apps/meet/_MeetLobby.d.ts +8 -0
- package/dist/apps/meet/_MeetLobby.js +15 -4
- package/dist/apps/meet/_ParticipantTile.d.ts +17 -2
- package/dist/apps/meet/_ParticipantTile.js +27 -4
- package/dist/apps/meet/_icons.d.ts +1 -0
- package/dist/apps/meet/_icons.js +1 -0
- package/dist/apps/meet/_layout.d.ts +1 -1
- package/dist/apps/meet/_layout.js +1 -1
- package/dist/apps/meet/_meetApi.d.ts +6 -0
- package/dist/apps/meet/_meetApi.js +1 -1
- package/dist/apps/meet/index.js +2 -2
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +4 -4
- package/dist/apps/settings-video-conferencing/_layout.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/_layout.js +1 -1
- package/dist/apps/settings-video-conferencing/index.d.ts +1 -1
- package/dist/apps/settings-video-conferencing/index.js +3 -3
- package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +78 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.js +329 -0
- package/dist/apps/shared/media/filters/accessories.d.ts +37 -0
- package/dist/apps/shared/media/filters/accessories.js +211 -0
- package/dist/apps/shared/media/filters/backgroundImage.d.ts +22 -0
- package/dist/apps/shared/media/filters/backgroundImage.js +54 -0
- package/dist/apps/shared/media/filters/filterTypes.d.ts +40 -0
- package/dist/apps/shared/media/filters/filterTypes.js +63 -0
- package/dist/apps/shared/media/filters/mlModels.d.ts +54 -0
- package/dist/apps/shared/media/filters/mlModels.js +106 -0
- package/dist/apps/shared/media/filters/pixelEffects.d.ts +14 -0
- package/dist/apps/shared/media/filters/pixelEffects.js +55 -0
- package/dist/apps/shared/media/mediaPreferences.d.ts +34 -0
- package/dist/apps/shared/media/mediaPreferences.js +93 -0
- package/dist/apps/shared/media/useLocalMedia.d.ts +17 -1
- package/dist/apps/shared/media/useLocalMedia.js +160 -14
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +1 -1
- package/dist/apps/shared/push/GuestSignalingClient.js +3 -3
- package/dist/apps/shared/relay/AudioPlayer.d.ts +38 -0
- package/dist/apps/shared/relay/AudioPlayer.js +103 -0
- package/dist/apps/shared/relay/AudioSender.d.ts +50 -0
- package/dist/apps/shared/relay/AudioSender.js +172 -0
- package/dist/apps/shared/relay/RelayClient.d.ts +65 -0
- package/dist/apps/shared/relay/RelayClient.js +236 -0
- package/dist/apps/shared/relay/RelayReceiver.d.ts +21 -0
- package/dist/apps/shared/relay/RelayReceiver.js +59 -0
- package/dist/apps/shared/relay/RelaySender.d.ts +34 -0
- package/dist/apps/shared/relay/RelaySender.js +54 -0
- package/dist/apps/shared/relay/RelayTransport.d.ts +38 -0
- package/dist/apps/shared/relay/RelayTransport.js +142 -0
- package/dist/apps/shared/relay/VideoPlayer.d.ts +31 -0
- package/dist/apps/shared/relay/VideoPlayer.js +102 -0
- package/dist/apps/shared/relay/VideoSender.d.ts +56 -0
- package/dist/apps/shared/relay/VideoSender.js +204 -0
- package/dist/apps/shared/relay/audioResume.d.ts +12 -0
- package/dist/apps/shared/relay/audioResume.js +45 -0
- package/dist/apps/shared/relay/frames.d.ts +101 -0
- package/dist/apps/shared/relay/frames.js +221 -0
- package/dist/apps/shared/relay/relayEnv.d.ts +181 -0
- package/dist/apps/shared/relay/relayEnv.js +69 -0
- package/dist/apps/shared/relay/senderCommon.d.ts +19 -0
- package/dist/apps/shared/relay/senderCommon.js +10 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +52 -2
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +138 -7
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
- package/dist/apps/shared/webrtc/realPeerConnection.js +20 -0
- package/dist/apps/shared/webrtc/types.d.ts +29 -2
- package/dist/lib/index.js +2 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +237 -3
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/EffectsUtils.js +21 -0
- package/dist/lib/util/EffectsUtils.js.map +1 -0
- package/dist/lib/util/IceServerUtils.js +25 -4
- package/dist/lib/util/IceServerUtils.js.map +1 -1
- package/dist/lib/util/RedisRelayBus.js +241 -0
- package/dist/lib/util/RedisRelayBus.js.map +1 -0
- package/dist/lib/util/RelayHub.js +405 -0
- package/dist/lib/util/RelayHub.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +79 -0
- package/dist/types/util/EffectsUtils.d.ts +10 -0
- package/dist/types/util/IceServerUtils.d.ts +12 -1
- package/dist/types/util/RedisRelayBus.d.ts +131 -0
- package/dist/types/util/RelayHub.d.ts +188 -0
- package/package.json +25 -11
|
@@ -0,0 +1,188 @@
|
|
|
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, type AudioContextLike, type RelayEnv } from "./relayEnv.js";
|
|
24
|
+
import { RelayReceiver } from "./RelayReceiver.js";
|
|
25
|
+
import { RelaySender } from "./RelaySender.js";
|
|
26
|
+
|
|
27
|
+
export interface RelayTransport {
|
|
28
|
+
/** false when this browser lacks WebSocket, AudioEncoder/AudioDecoder/VideoEncoder/VideoDecoder, VideoFrame,
|
|
29
|
+
* AudioContext or canvas.captureStream - the mesh then never falls back to the relay. */
|
|
30
|
+
readonly supported: boolean;
|
|
31
|
+
/** Start receiving `peerId`'s media (opens/keeps the socket, adds to `want`). `onStream` gets a MediaStream that
|
|
32
|
+
* plays that peer (one audio + one video track). Called at most once per stream creation; calling receiveFrom
|
|
33
|
+
* again for the same peer is a no-op. */
|
|
34
|
+
receiveFrom(peerId: string, onStream: (stream: MediaStream) => void): void;
|
|
35
|
+
stopReceivingFrom(peerId: string): void;
|
|
36
|
+
/** Start/stop publishing the local tracks (encode + send). Only encodes while active; stopping releases the
|
|
37
|
+
* encoders and audio nodes. */
|
|
38
|
+
setSending(active: boolean): void;
|
|
39
|
+
/** Same semantics as `MeshConnectionManager.setLocalTrack`: null = send nothing for that kind. Safe to call
|
|
40
|
+
* before or after `setSending`. */
|
|
41
|
+
setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void;
|
|
42
|
+
/** Closes the socket, all coders, streams and timers. Idempotent. */
|
|
43
|
+
close(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RelayTransportOptions {
|
|
47
|
+
meetingUid: string;
|
|
48
|
+
/** This participant's peer id on the meeting's signaling channel - the id the server stamps on what it sends. */
|
|
49
|
+
peerId: string;
|
|
50
|
+
/** Overrides the socket URL (default: `relayUrl(meetingUid)`). */
|
|
51
|
+
url?: string;
|
|
52
|
+
/** Overrides every browser API the relay uses (default: `detectRelayEnv()`), so a test can drive it with fakes. */
|
|
53
|
+
env?: RelayEnv;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The relay WebSocket's URL for a meeting: this deployment's origin (the API origin when the UI is served from a
|
|
57
|
+
* different one, else the page's own), `http` becoming `ws` - the same derivation `pushUrl()` uses for `/push`.
|
|
58
|
+
* `undefined` when there is no origin at all (not running in a browser). */
|
|
59
|
+
export function relayUrl(meetingUid: string): string | undefined {
|
|
60
|
+
const origin = apiOrigin() || (typeof window !== "undefined" ? window.location.origin : "");
|
|
61
|
+
return origin ? `${origin.replace(/^http/i, "ws")}/api/mail/video-meetings/relay/${encodeURIComponent(meetingUid)}` : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What `createRelayTransport()` returns on a browser that cannot run the relay: every call is a no-op. */
|
|
65
|
+
const UNSUPPORTED_TRANSPORT: RelayTransport = {
|
|
66
|
+
supported: false,
|
|
67
|
+
receiveFrom: () => undefined,
|
|
68
|
+
stopReceivingFrom: () => undefined,
|
|
69
|
+
setSending: () => undefined,
|
|
70
|
+
setLocalTrack: () => undefined,
|
|
71
|
+
close: () => undefined,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
class WebSocketRelayTransport implements RelayTransport {
|
|
75
|
+
readonly supported = true;
|
|
76
|
+
private readonly client: RelayClient;
|
|
77
|
+
private readonly sender: RelaySender;
|
|
78
|
+
private readonly receivers = new Map<string, RelayReceiver>();
|
|
79
|
+
private playback: AudioContextLike | undefined;
|
|
80
|
+
private stopResume: (() => void) | undefined;
|
|
81
|
+
private closed = false;
|
|
82
|
+
|
|
83
|
+
constructor(
|
|
84
|
+
private readonly env: RelayEnv,
|
|
85
|
+
url: string,
|
|
86
|
+
peerId: string,
|
|
87
|
+
) {
|
|
88
|
+
this.client = new RelayClient({
|
|
89
|
+
url,
|
|
90
|
+
peerId,
|
|
91
|
+
createSocket: (target) => env.createSocket(target),
|
|
92
|
+
random: () => env.random(),
|
|
93
|
+
onMedia: (sender, payload) => this.receivers.get(sender)?.handleMedia(payload),
|
|
94
|
+
});
|
|
95
|
+
this.sender = new RelaySender({
|
|
96
|
+
env,
|
|
97
|
+
send: (message) => this.client.sendMedia(message),
|
|
98
|
+
canSend: () => this.client.ready,
|
|
99
|
+
maxMessageBytes: () => this.client.maxMessageBytes,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
receiveFrom(peerId: string, onStream: (stream: MediaStream) => void): void {
|
|
104
|
+
if (this.closed || this.receivers.has(peerId)) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const receiver = this.createReceiver();
|
|
108
|
+
if (!receiver) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.receivers.set(peerId, receiver);
|
|
112
|
+
this.client.start();
|
|
113
|
+
this.client.setWanted(this.receivers.keys());
|
|
114
|
+
onStream(receiver.stream);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
stopReceivingFrom(peerId: string): void {
|
|
118
|
+
const receiver = this.receivers.get(peerId);
|
|
119
|
+
if (!receiver) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
this.receivers.delete(peerId);
|
|
123
|
+
receiver.close();
|
|
124
|
+
this.client.setWanted(this.receivers.keys());
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
setSending(active: boolean): void {
|
|
128
|
+
if (this.closed) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (active) {
|
|
132
|
+
this.client.start();
|
|
133
|
+
}
|
|
134
|
+
this.sender.setActive(active);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setLocalTrack(kind: "audio" | "video", track: MediaStreamTrack | null): void {
|
|
138
|
+
if (!this.closed) {
|
|
139
|
+
this.sender.setTrack(kind, track);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
close(): void {
|
|
144
|
+
if (this.closed) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.closed = true;
|
|
148
|
+
this.sender.setActive(false);
|
|
149
|
+
for (const receiver of this.receivers.values()) {
|
|
150
|
+
receiver.close();
|
|
151
|
+
}
|
|
152
|
+
this.receivers.clear();
|
|
153
|
+
this.client.close();
|
|
154
|
+
this.stopResume?.();
|
|
155
|
+
// `close()` rejects for a context that is already closed; nothing to do about it.
|
|
156
|
+
this.playback?.close().catch(() => undefined);
|
|
157
|
+
this.playback = undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A receiver on the shared playback context, or `undefined` if this browser will not build one (the peer then
|
|
161
|
+
* simply has no relayed media). */
|
|
162
|
+
private createReceiver(): RelayReceiver | undefined {
|
|
163
|
+
try {
|
|
164
|
+
if (!this.playback) {
|
|
165
|
+
this.playback = this.env.createAudioContext(PLAYBACK_SAMPLE_RATE);
|
|
166
|
+
// Nothing the user just did created this context, so it may start suspended - see `resumeAudioContext`.
|
|
167
|
+
this.stopResume = resumeAudioContext(this.playback, this.env.document);
|
|
168
|
+
}
|
|
169
|
+
return new RelayReceiver(this.env, this.playback);
|
|
170
|
+
} catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Creates the relay transport for one meeting. Returns an inert transport (`supported: false`) when the browser lacks
|
|
178
|
+
* something the relay needs, so the caller can simply check `supported` once and never fall back to it.
|
|
179
|
+
* No socket is opened until the first `receiveFrom()` or `setSending(true)`.
|
|
180
|
+
*/
|
|
181
|
+
export function createRelayTransport(options: RelayTransportOptions): RelayTransport {
|
|
182
|
+
const env = options.env ?? detectRelayEnv();
|
|
183
|
+
const url = options.url ?? relayUrl(options.meetingUid);
|
|
184
|
+
if (!env || !url) {
|
|
185
|
+
return UNSUPPORTED_TRANSPORT;
|
|
186
|
+
}
|
|
187
|
+
return new WebSocketRelayTransport(env, url, options.peerId);
|
|
188
|
+
}
|
|
@@ -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
|
+
}
|