@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.
- package/README.md +15 -2
- package/apps/meet/[token].tsx +3 -2
- package/apps/meet/_CallControls.tsx +28 -2
- package/apps/meet/_CallView.tsx +23 -2
- package/apps/meet/_EffectsPanel.tsx +134 -0
- package/apps/meet/_MeetLobby.tsx +22 -0
- package/apps/meet/_ParticipantTile.tsx +62 -9
- package/apps/meet/_icons.tsx +4 -0
- package/apps/meet/_meetApi.ts +6 -0
- package/apps/shared/media/filters/VideoFilterProcessor.ts +383 -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/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 +158 -6
- package/apps/shared/webrtc/realPeerConnection.ts +22 -0
- package/apps/shared/webrtc/types.ts +32 -2
- package/dist/apps/meet/[token].js +3 -3
- 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 +17 -3
- package/dist/apps/meet/_EffectsPanel.d.ts +17 -0
- package/dist/apps/meet/_EffectsPanel.js +60 -0
- package/dist/apps/meet/_MeetLobby.d.ts +4 -0
- package/dist/apps/meet/_MeetLobby.js +8 -1
- package/dist/apps/meet/_ParticipantTile.d.ts +7 -1
- package/dist/apps/meet/_ParticipantTile.js +20 -3
- package/dist/apps/meet/_icons.d.ts +1 -0
- package/dist/apps/meet/_icons.js +1 -0
- package/dist/apps/meet/_meetApi.d.ts +6 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.d.ts +75 -0
- package/dist/apps/shared/media/filters/VideoFilterProcessor.js +304 -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/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 +51 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +137 -6
- 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 +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
|
+
}
|