@q9labsai/chalk-client 5.2.6 → 5.3.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 +1 -1
- package/dist/access/grant.d.ts +2 -0
- package/dist/{chunk-I7Y4HR2P.js → chunk-2CEMOTZF.js} +333 -64
- package/dist/{chunk-5YPQNMJE.js → chunk-FD7ZSDTM.js} +103 -26
- package/dist/{chunk-CJVLTSIO.js → chunk-MHJ4UPM3.js} +165 -6
- package/dist/chunk-N4IPKRS6.js +76 -0
- package/dist/{chunk-YIDKETOX.js → chunk-O5JB5XVX.js} +840 -38
- package/dist/{chunk-WFRTUBN6.js → chunk-VAJ6XNAA.js} +1 -1
- package/dist/client.d.ts +182 -1
- package/dist/connection/dependencies.d.ts +2 -1
- package/dist/effect.js +29 -4
- package/dist/generated/http-api.d.ts +180 -1
- package/dist/generated/schemas.d.ts +290 -1
- package/dist/generated/sync.d.ts +60 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +33 -15
- package/dist/media/call-stats.d.ts +27 -0
- package/dist/media/client.d.ts +2 -2
- package/dist/media/ice-servers.d.ts +3 -0
- package/dist/media/index.d.ts +2 -0
- package/dist/media/index.js +4 -1
- package/dist/media/plane.d.ts +4 -2
- package/dist/media/receive-stall.d.ts +52 -0
- package/dist/media/types.d.ts +21 -0
- package/dist/media/video-render-size.d.ts +7 -0
- package/dist/media/viewer-layer.d.ts +48 -0
- package/dist/public-invites/index.js +3 -2
- package/dist/server/index.cjs +19 -4
- package/dist/server/index.js +19 -4
- package/dist/server/types.d.cts +2 -0
- package/dist/server/types.d.ts +2 -0
- package/dist/session/index.d.ts +18 -0
- package/dist/space-client/same-identity.d.ts +6 -0
- package/dist/space-client/types.d.ts +6 -0
- package/dist/sync/index.js +2 -1
- package/dist/sync/v1-command-scheduler.d.ts +4 -0
- package/dist/sync/v1-types.d.ts +6 -0
- package/package.json +3 -1
|
@@ -1,3 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
withRequestContext
|
|
3
|
+
} from "./chunk-N4IPKRS6.js";
|
|
4
|
+
|
|
5
|
+
// src/media/receive-stall.ts
|
|
6
|
+
var RECEIVE_STALL_WINDOW_MS = 7e3;
|
|
7
|
+
var RECEIVE_STARTUP_GRACE_MS = 2e4;
|
|
8
|
+
var MAX_SAMPLE_GAP_MS = 3e3;
|
|
9
|
+
var ReceiveStallDetector = class {
|
|
10
|
+
#progress = /* @__PURE__ */ new Map();
|
|
11
|
+
#sampledAt;
|
|
12
|
+
forget(publicationId) {
|
|
13
|
+
this.#progress.delete(publicationId);
|
|
14
|
+
}
|
|
15
|
+
clear() {
|
|
16
|
+
this.#progress.clear();
|
|
17
|
+
this.#sampledAt = void 0;
|
|
18
|
+
}
|
|
19
|
+
sample(now, tracks) {
|
|
20
|
+
if (this.#sampledAt !== void 0 && (now - this.#sampledAt > MAX_SAMPLE_GAP_MS || now < this.#sampledAt)) this.clear();
|
|
21
|
+
this.#sampledAt = now;
|
|
22
|
+
const observed = new Set(tracks.map((track) => track.publicationId));
|
|
23
|
+
for (const id of this.#progress.keys()) if (!observed.has(id)) this.#progress.delete(id);
|
|
24
|
+
const observations = tracks.map((track) => ({ id: track.publicationId, outcome: this.#observeTrack(now, track) }));
|
|
25
|
+
return {
|
|
26
|
+
stalled: observations.filter((track) => track.outcome === "stalled").map((track) => track.id),
|
|
27
|
+
resumed: observations.filter((track) => track.outcome === "resumed").map((track) => track.id),
|
|
28
|
+
advanced: observations.filter((track) => track.outcome === "advanced" || track.outcome === "resumed").map((track) => track.id)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
#observeTrack(now, { publicationId, trackId, packets }) {
|
|
32
|
+
const previous = this.#progress.get(publicationId);
|
|
33
|
+
const sameTrack = previous?.trackId === trackId;
|
|
34
|
+
if (sameTrack && previous.packets === packets) {
|
|
35
|
+
const window = previous.flowing ? RECEIVE_STALL_WINDOW_MS : RECEIVE_STARTUP_GRACE_MS;
|
|
36
|
+
return now - previous.advancedAt >= window ? "stalled" : null;
|
|
37
|
+
}
|
|
38
|
+
return this.#advanceTrack(now, { publicationId, trackId, packets }, previous);
|
|
39
|
+
}
|
|
40
|
+
#advanceTrack(now, { publicationId, trackId, packets }, previous) {
|
|
41
|
+
const advanced = previous?.trackId === trackId && packets > previous.packets;
|
|
42
|
+
const flowingSince = advanced && now - previous.advancedAt < RECEIVE_STALL_WINDOW_MS ? previous.flowingSince : now;
|
|
43
|
+
this.#progress.set(publicationId, { trackId, packets, advancedAt: now, flowing: advanced, flowingSince });
|
|
44
|
+
if (!advanced) return null;
|
|
45
|
+
return now - flowingSince >= RECEIVE_STALL_WINDOW_MS ? "resumed" : "advanced";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var MAX_RECEIVE_REBUILDS = 2;
|
|
49
|
+
var MAX_RECEIVE_OBSERVATIONS = 32;
|
|
50
|
+
var ReceiveStallRecovery = class {
|
|
51
|
+
#detector = new ReceiveStallDetector();
|
|
52
|
+
#attempts = /* @__PURE__ */ new Map();
|
|
53
|
+
#sources = /* @__PURE__ */ new Map();
|
|
54
|
+
#repulled = /* @__PURE__ */ new Set();
|
|
55
|
+
// Re-pulled but not yet restored. Retired receivers vanish from stats, so this cannot depend on detector samples.
|
|
56
|
+
#awaiting = /* @__PURE__ */ new Set();
|
|
57
|
+
#identities = /* @__PURE__ */ new Map();
|
|
58
|
+
#expected = /* @__PURE__ */ new Set();
|
|
59
|
+
#screens = /* @__PURE__ */ new Set();
|
|
60
|
+
#stalled = /* @__PURE__ */ new Set();
|
|
61
|
+
#active = [];
|
|
62
|
+
#observations = Object.freeze([]);
|
|
63
|
+
get observations() {
|
|
64
|
+
return this.#observations;
|
|
65
|
+
}
|
|
66
|
+
expect(publications, participantId) {
|
|
67
|
+
const enabled = publications.filter((publication) => publication.participantId !== participantId && publication.enabled);
|
|
68
|
+
this.#expected = new Set(enabled.flatMap((publication) => publication.publicationId ? [publication.publicationId] : []));
|
|
69
|
+
this.#screens = new Set(enabled.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
|
|
70
|
+
this.#pruneWithdrawn(publications);
|
|
71
|
+
this.#pruneAttempts(publications);
|
|
72
|
+
for (const publication of this.#identities.values()) {
|
|
73
|
+
if (this.#isExpected(publication)) continue;
|
|
74
|
+
this.#detector.forget(publication.publicationId);
|
|
75
|
+
this.#stalled.delete(publication.publicationId);
|
|
76
|
+
this.#awaiting.delete(publication.publicationId);
|
|
77
|
+
}
|
|
78
|
+
this.#active = [...this.#identities.values()].filter((publication) => this.#isExpected(publication)).map((publication) => publication.publicationId);
|
|
79
|
+
}
|
|
80
|
+
suspend() {
|
|
81
|
+
this.#expected.clear();
|
|
82
|
+
this.#screens.clear();
|
|
83
|
+
this.clear();
|
|
84
|
+
}
|
|
85
|
+
#pruneWithdrawn(publications) {
|
|
86
|
+
const advertised = new Set(publications.map((publication) => publication.publicationId));
|
|
87
|
+
const screens = new Set(publications.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
|
|
88
|
+
for (const [key, publication] of this.#identities) {
|
|
89
|
+
if (advertised.has(publication.publicationId) || publication.source === "screen_audio" && screens.has(publication.participantId)) continue;
|
|
90
|
+
this.#forget(publication.publicationId);
|
|
91
|
+
this.#identities.delete(key);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
#pruneAttempts(publications) {
|
|
95
|
+
const advertised = new Set(publications.flatMap((publication) => publication.source === "screen" ? [sourceKey(publication), sourceKey({ ...publication, source: "screen_audio" })] : [sourceKey(publication)]));
|
|
96
|
+
for (const key of this.#attempts.keys()) if (!advertised.has(key)) this.#attempts.delete(key);
|
|
97
|
+
}
|
|
98
|
+
isStalled(publicationId) {
|
|
99
|
+
return this.#stalled.has(publicationId);
|
|
100
|
+
}
|
|
101
|
+
canRepull(id) {
|
|
102
|
+
return this.#stalled.has(id) && !this.#repulled.has(id);
|
|
103
|
+
}
|
|
104
|
+
reserveRepull(id) {
|
|
105
|
+
if (!this.canRepull(id)) return false;
|
|
106
|
+
this.#repulled.add(id);
|
|
107
|
+
this.#awaiting.add(id);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
restarted(id) {
|
|
111
|
+
this.#detector.forget(id);
|
|
112
|
+
this.#stalled.delete(id);
|
|
113
|
+
this.#awaiting.delete(id);
|
|
114
|
+
}
|
|
115
|
+
canRebuild(id) {
|
|
116
|
+
return this.#active.some((active) => (id === void 0 || id === active) && this.#stalled.has(active) && this.#rebuildsLeft(active));
|
|
117
|
+
}
|
|
118
|
+
reserveRebuild() {
|
|
119
|
+
if (!this.canRebuild()) return false;
|
|
120
|
+
for (const id of this.#active) {
|
|
121
|
+
const source = this.#sources.get(id);
|
|
122
|
+
if (source !== void 0 && this.canRebuild(id)) this.#attempts.set(source, (this.#attempts.get(source) ?? 0) + 1);
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
#rebuildsLeft(id) {
|
|
127
|
+
const source = this.#sources.get(id);
|
|
128
|
+
return source !== void 0 && (this.#attempts.get(source) ?? 0) < MAX_RECEIVE_REBUILDS;
|
|
129
|
+
}
|
|
130
|
+
/** The media connection was replaced: its receivers are gone, so nothing is awaiting a restore. */
|
|
131
|
+
replaced() {
|
|
132
|
+
this.#awaiting.clear();
|
|
133
|
+
this.clear();
|
|
134
|
+
}
|
|
135
|
+
clear() {
|
|
136
|
+
this.#detector.clear();
|
|
137
|
+
this.#stalled.clear();
|
|
138
|
+
this.#active = [];
|
|
139
|
+
}
|
|
140
|
+
sample(now, connection, report, tracks) {
|
|
141
|
+
const active = [...tracks].filter((publication) => this.#isExpected(publication));
|
|
142
|
+
for (const publication of active) this.#rememberIdentity(publication);
|
|
143
|
+
this.#active = active.map((publication) => publication.publicationId);
|
|
144
|
+
const samples = active.flatMap((publication) => receivePacketSample(connection, report, publication) ?? []);
|
|
145
|
+
const { stalled, advanced, resumed } = this.#detector.sample(now, samples);
|
|
146
|
+
for (const id of resumed) {
|
|
147
|
+
const source = this.#sources.get(id);
|
|
148
|
+
if (source !== void 0) this.#attempts.delete(source);
|
|
149
|
+
this.#repulled.delete(id);
|
|
150
|
+
this.#awaiting.delete(id);
|
|
151
|
+
}
|
|
152
|
+
const newlyStalled = stalled.filter((id) => !this.#stalled.has(id));
|
|
153
|
+
const unrestored = this.#active.filter((id) => this.#awaiting.has(id) && !stalled.includes(id));
|
|
154
|
+
this.#stalled = /* @__PURE__ */ new Set([...stalled, ...unrestored]);
|
|
155
|
+
if (newlyStalled.length) this.#record(now);
|
|
156
|
+
return { stalled: [...stalled, ...unrestored], advanced, newlyStalled };
|
|
157
|
+
}
|
|
158
|
+
#isExpected(publication) {
|
|
159
|
+
return publication.source === "screen_audio" ? this.#screens.has(publication.participantId) : this.#expected.has(publication.publicationId);
|
|
160
|
+
}
|
|
161
|
+
#rememberIdentity(publication) {
|
|
162
|
+
const key = sourceKey(publication);
|
|
163
|
+
const previous = this.#identities.get(key);
|
|
164
|
+
if (previous && previous.publicationId !== publication.publicationId) this.#forget(previous.publicationId);
|
|
165
|
+
this.#identities.set(key, publication);
|
|
166
|
+
this.#sources.set(publication.publicationId, key);
|
|
167
|
+
}
|
|
168
|
+
#forget(id) {
|
|
169
|
+
this.#detector.forget(id);
|
|
170
|
+
this.#stalled.delete(id);
|
|
171
|
+
this.#awaiting.delete(id);
|
|
172
|
+
this.#repulled.delete(id);
|
|
173
|
+
this.#sources.delete(id);
|
|
174
|
+
}
|
|
175
|
+
#record(now) {
|
|
176
|
+
const observation = Object.freeze({ event: "receive_stalled", scope: this.#active.every((id) => this.#stalled.has(id)) ? "all" : "partial", observedAtMs: now, expectedTracks: this.#active.length, stalledTracks: this.#stalled.size });
|
|
177
|
+
this.#observations = Object.freeze([...this.#observations.slice(-(MAX_RECEIVE_OBSERVATIONS - 1)), observation]);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
function sourceKey({ participantId, source }) {
|
|
181
|
+
return `${participantId}\0${source}`;
|
|
182
|
+
}
|
|
183
|
+
function receivePacketSample(connection, report, publication) {
|
|
184
|
+
const rows = [...report.values()].filter((row) => row.type === "inbound-rtp" && row.trackIdentifier === publication.track.id);
|
|
185
|
+
if (rows.some((row) => !Number.isSafeInteger(row.packetsReceived) || row.packetsReceived < 0)) return;
|
|
186
|
+
if (publication.track.kind === "audio" && !audioPacketCadenceKnown(connection, report, publication.track, rows)) return;
|
|
187
|
+
return { publicationId: publication.publicationId, trackId: publication.track.id, packets: rows.reduce((sum2, row) => sum2 + row.packetsReceived, 0) };
|
|
188
|
+
}
|
|
189
|
+
function audioPacketCadenceKnown(connection, report, track, rows) {
|
|
190
|
+
const negotiated = rows.length ? rows.map((row) => row.codecId ? report.get(row.codecId) : void 0) : connection.getReceivers().find((receiver) => receiver.track.id === track.id)?.getParameters().codecs ?? [];
|
|
191
|
+
const opus = negotiated.filter((codec) => codec?.mimeType?.toLowerCase() === "audio/opus");
|
|
192
|
+
return opus.length > 0 && opus.every((codec) => typeof codec.sdpFmtpLine === "string");
|
|
193
|
+
}
|
|
194
|
+
|
|
1
195
|
// src/media/camera-uplink.ts
|
|
2
196
|
var CameraUplinkPolicy = class {
|
|
3
197
|
#healthySamples = 0;
|
|
@@ -24,13 +218,223 @@ var CameraUplinkPolicy = class {
|
|
|
24
218
|
this.#healthySamples = 0;
|
|
25
219
|
}
|
|
26
220
|
};
|
|
27
|
-
function cameraUplinkBitrate(
|
|
28
|
-
for (const stat of
|
|
221
|
+
function cameraUplinkBitrate(stats2) {
|
|
222
|
+
for (const stat of stats2) {
|
|
29
223
|
if (stat.type === "candidate-pair" && stat.state === "succeeded" && (stat.nominated || stat.selected)) return stat.availableOutgoingBitrate;
|
|
30
224
|
}
|
|
31
225
|
return void 0;
|
|
32
226
|
}
|
|
33
227
|
|
|
228
|
+
// src/media/video-render-size.ts
|
|
229
|
+
var sizes = /* @__PURE__ */ new WeakMap();
|
|
230
|
+
function setVideoRenderSize(track, size) {
|
|
231
|
+
if (size === null) sizes.delete(track);
|
|
232
|
+
else sizes.set(track, size);
|
|
233
|
+
}
|
|
234
|
+
function videoRenderSize(track) {
|
|
235
|
+
return sizes.get(track);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/media/viewer-layer.ts
|
|
239
|
+
var LOW_TILE_MAX_HEIGHT = 400;
|
|
240
|
+
var HIGH_TILE_MIN_HEIGHT = 480;
|
|
241
|
+
var BAD_LOSS = 0.05;
|
|
242
|
+
var SEVERE_LOSS = 0.15;
|
|
243
|
+
var SEVERE_FREEZE_SECONDS = 1;
|
|
244
|
+
var GOOD_LOSS = 0.02;
|
|
245
|
+
var MIN_FRAMES_PER_SECOND = 8;
|
|
246
|
+
var DROP_AFTER_BAD_SAMPLES = 2;
|
|
247
|
+
var RISE_AFTER_GOOD_SAMPLES = 15;
|
|
248
|
+
var RISE_AFTER_TILE_GROWS_SAMPLES = 2;
|
|
249
|
+
var SETTLE_SAMPLES = 2;
|
|
250
|
+
var FAILED_PROBE_WITHIN_SAMPLES = 30;
|
|
251
|
+
var STABLE_SAMPLES_RESETTING_BACKOFF = 60;
|
|
252
|
+
var MAX_RISE_BACKOFF = 8;
|
|
253
|
+
var FRAME_RATE_COMPARISON_SAMPLES = 3;
|
|
254
|
+
var ViewerLayerPolicy = class {
|
|
255
|
+
#layer = "h";
|
|
256
|
+
#tileIsSmall = false;
|
|
257
|
+
#limitedByNetwork = false;
|
|
258
|
+
#badSamples = 0;
|
|
259
|
+
#goodSamples = 0;
|
|
260
|
+
#stableSamples = 0;
|
|
261
|
+
#samplesSinceRise = Number.POSITIVE_INFINITY;
|
|
262
|
+
#settling = 0;
|
|
263
|
+
#backoff = 1;
|
|
264
|
+
#frameRateProbe;
|
|
265
|
+
#senderFrameRate;
|
|
266
|
+
#unimprovedSamples = 0;
|
|
267
|
+
get layer() {
|
|
268
|
+
return this.#layer;
|
|
269
|
+
}
|
|
270
|
+
/** Drops fast, rises slowly: a failed attempt at h doubles the wait before the next one. */
|
|
271
|
+
sample(sample) {
|
|
272
|
+
this.#tileIsSmall = tileIsSmall(this.#tileIsSmall, sample.tileHeight);
|
|
273
|
+
if (this.#layer === "h" && sample.framesPerSecond !== void 0 && sample.framesPerSecond >= MIN_FRAMES_PER_SECOND) this.#senderFrameRate = void 0;
|
|
274
|
+
const checkFrameRate = this.#layer === "h" && (this.#senderFrameRate === void 0 || (sample.framesPerSecond ?? 0) < this.#senderFrameRate * 0.75);
|
|
275
|
+
const health = this.#settling > 0 ? this.#settle() : classify(sample, checkFrameRate);
|
|
276
|
+
return this.#layer === "h" ? this.#sampleHigh(health, sample) : this.#sampleLow(health, sample);
|
|
277
|
+
}
|
|
278
|
+
#settle() {
|
|
279
|
+
this.#settling--;
|
|
280
|
+
return "unknown";
|
|
281
|
+
}
|
|
282
|
+
#sampleHigh(health, sample) {
|
|
283
|
+
this.#samplesSinceRise++;
|
|
284
|
+
if (this.#tileIsSmall) return this.#drop(false);
|
|
285
|
+
if (health === "bad" || health === "severe") {
|
|
286
|
+
this.#stableSamples = 0;
|
|
287
|
+
if (health === "severe" || ++this.#badSamples >= DROP_AFTER_BAD_SAMPLES) return this.#drop(true, frameRateProbe(sample, health));
|
|
288
|
+
return this.#layer;
|
|
289
|
+
}
|
|
290
|
+
this.#badSamples = 0;
|
|
291
|
+
if (health === "good" && ++this.#stableSamples >= STABLE_SAMPLES_RESETTING_BACKOFF) this.#backoff = 1;
|
|
292
|
+
return this.#layer;
|
|
293
|
+
}
|
|
294
|
+
#sampleLow(health, sample) {
|
|
295
|
+
if (this.#tileIsSmall || health !== "good") {
|
|
296
|
+
this.#goodSamples = 0;
|
|
297
|
+
this.#unimprovedSamples = 0;
|
|
298
|
+
return this.#layer;
|
|
299
|
+
}
|
|
300
|
+
if (this.#detectSenderLimit(sample.framesPerSecond)) {
|
|
301
|
+
this.#senderFrameRate = this.#frameRateProbe;
|
|
302
|
+
this.#backoff = 1;
|
|
303
|
+
return this.#rise();
|
|
304
|
+
}
|
|
305
|
+
const needed = this.#limitedByNetwork ? RISE_AFTER_GOOD_SAMPLES * this.#backoff : RISE_AFTER_TILE_GROWS_SAMPLES;
|
|
306
|
+
if (++this.#goodSamples < needed) return this.#layer;
|
|
307
|
+
return this.#rise();
|
|
308
|
+
}
|
|
309
|
+
#detectSenderLimit(fps) {
|
|
310
|
+
if (this.#frameRateProbe !== void 0 && fps !== void 0 && fps > 0 && fps <= this.#frameRateProbe * 1.25) return ++this.#unimprovedSamples >= FRAME_RATE_COMPARISON_SAMPLES;
|
|
311
|
+
this.#unimprovedSamples = 0;
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
#drop(byNetwork, frameRate) {
|
|
315
|
+
this.#frameRateProbe = frameRate;
|
|
316
|
+
this.#unimprovedSamples = 0;
|
|
317
|
+
if (byNetwork) {
|
|
318
|
+
if (this.#samplesSinceRise < FAILED_PROBE_WITHIN_SAMPLES) this.#backoff = Math.min(this.#backoff * 2, MAX_RISE_BACKOFF);
|
|
319
|
+
this.#limitedByNetwork = true;
|
|
320
|
+
}
|
|
321
|
+
return this.#switchTo("l");
|
|
322
|
+
}
|
|
323
|
+
#rise() {
|
|
324
|
+
this.#limitedByNetwork = false;
|
|
325
|
+
this.#samplesSinceRise = 0;
|
|
326
|
+
return this.#switchTo("h");
|
|
327
|
+
}
|
|
328
|
+
#switchTo(layer) {
|
|
329
|
+
this.#layer = layer;
|
|
330
|
+
this.#badSamples = 0;
|
|
331
|
+
this.#goodSamples = 0;
|
|
332
|
+
this.#stableSamples = 0;
|
|
333
|
+
this.#settling = SETTLE_SAMPLES;
|
|
334
|
+
return layer;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
function tileIsSmall(previous, height) {
|
|
338
|
+
if (height === void 0) return previous;
|
|
339
|
+
if (height <= LOW_TILE_MAX_HEIGHT) return true;
|
|
340
|
+
return height < HIGH_TILE_MIN_HEIGHT ? previous : false;
|
|
341
|
+
}
|
|
342
|
+
function isSevere({ lossFraction, freezeSeconds }) {
|
|
343
|
+
return freezeSeconds >= SEVERE_FREEZE_SECONDS || lossFraction !== void 0 && lossFraction >= SEVERE_LOSS;
|
|
344
|
+
}
|
|
345
|
+
function isBad({ lossFraction, framesPerSecond, newFreezes }, checkFrameRate) {
|
|
346
|
+
if (newFreezes > 0 || lossFraction !== void 0 && lossFraction >= BAD_LOSS) return true;
|
|
347
|
+
return checkFrameRate && lossFraction !== void 0 && framesPerSecond !== void 0 && framesPerSecond < MIN_FRAMES_PER_SECOND;
|
|
348
|
+
}
|
|
349
|
+
function frameRateProbe(sample, health) {
|
|
350
|
+
return health === "severe" || isBad(sample, false) ? void 0 : sample.framesPerSecond;
|
|
351
|
+
}
|
|
352
|
+
function classify(sample, checkFrameRate) {
|
|
353
|
+
if (isSevere(sample)) return "severe";
|
|
354
|
+
if (isBad(sample, checkFrameRate)) return "bad";
|
|
355
|
+
const { lossFraction, framesPerSecond } = sample;
|
|
356
|
+
if (lossFraction === void 0 || framesPerSecond === void 0) return "unknown";
|
|
357
|
+
return lossFraction < GOOD_LOSS ? "good" : "unknown";
|
|
358
|
+
}
|
|
359
|
+
var ViewerStatsReader = class {
|
|
360
|
+
#previous = /* @__PURE__ */ new Map();
|
|
361
|
+
read(stats2, trackId) {
|
|
362
|
+
const row = [...stats2].find((stat) => stat.type === "inbound-rtp" && stat.kind === "video" && stat.trackIdentifier === trackId);
|
|
363
|
+
const counters = row && inboundCounters(row);
|
|
364
|
+
if (!counters) return void 0;
|
|
365
|
+
const previous = this.#previous.get(trackId);
|
|
366
|
+
this.#previous.set(trackId, counters);
|
|
367
|
+
if (!previous || counters.timestamp <= previous.timestamp) return void 0;
|
|
368
|
+
const received = counters.packetsReceived - previous.packetsReceived;
|
|
369
|
+
const lost = Math.max(0, counters.packetsLost - previous.packetsLost);
|
|
370
|
+
const frames = counters.framesDecoded - previous.framesDecoded;
|
|
371
|
+
if (received < 0 || frames < 0) return void 0;
|
|
372
|
+
return {
|
|
373
|
+
lossFraction: received + lost > 0 ? lost / (received + lost) : void 0,
|
|
374
|
+
framesPerSecond: frames * 1e3 / (counters.timestamp - previous.timestamp),
|
|
375
|
+
newFreezes: Math.max(0, counters.freezeCount - previous.freezeCount),
|
|
376
|
+
freezeSeconds: Math.max(0, counters.freezeSeconds - previous.freezeSeconds)
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
forget(trackId) {
|
|
380
|
+
this.#previous.delete(trackId);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
function inboundCounters(row) {
|
|
384
|
+
const { timestamp, packetsReceived, packetsLost, framesDecoded } = row;
|
|
385
|
+
if (timestamp === void 0 || packetsReceived === void 0 || packetsLost === void 0 || framesDecoded === void 0) return void 0;
|
|
386
|
+
return { timestamp, packetsReceived, packetsLost, framesDecoded, freezeCount: row.freezeCount ?? 0, freezeSeconds: row.totalFreezesDuration ?? 0 };
|
|
387
|
+
}
|
|
388
|
+
var RETRY_AFTER_FAILED_SWITCH_MS = 5e3;
|
|
389
|
+
var MAX_RETRY_DELAY_MS = 6e4;
|
|
390
|
+
var ViewerLayerSelector = class {
|
|
391
|
+
#views = /* @__PURE__ */ new Map();
|
|
392
|
+
#reader = new ViewerStatsReader();
|
|
393
|
+
sample(now, report, cameras) {
|
|
394
|
+
const rows = [...report.values()];
|
|
395
|
+
const active = new Set(cameras.map((camera) => camera.publicationId));
|
|
396
|
+
for (const [publicationId, view] of this.#views) {
|
|
397
|
+
if (!active.has(publicationId)) {
|
|
398
|
+
this.#reader.forget(view.track.id);
|
|
399
|
+
this.#views.delete(publicationId);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const changes = [];
|
|
403
|
+
for (const camera of cameras) {
|
|
404
|
+
const view = this.#view(camera);
|
|
405
|
+
const stats2 = this.#reader.read(rows, camera.track.id);
|
|
406
|
+
if (!stats2) continue;
|
|
407
|
+
const layer = view.policy.layer === view.applied ? view.policy.sample({ ...stats2, tileHeight: videoRenderSize(camera.track)?.height }) : view.policy.layer;
|
|
408
|
+
if (layer !== view.applied && now >= view.retryAt) changes.push({ ...camera, layer });
|
|
409
|
+
}
|
|
410
|
+
return changes;
|
|
411
|
+
}
|
|
412
|
+
applied({ publicationId, track, layer }) {
|
|
413
|
+
const view = this.#views.get(publicationId);
|
|
414
|
+
if (!view || view.track !== track) return;
|
|
415
|
+
view.applied = layer;
|
|
416
|
+
view.failures = 0;
|
|
417
|
+
}
|
|
418
|
+
failed({ publicationId, track }, now) {
|
|
419
|
+
const view = this.#views.get(publicationId);
|
|
420
|
+
if (!view || view.track !== track) return;
|
|
421
|
+
view.retryAt = now + Math.min(MAX_RETRY_DELAY_MS, RETRY_AFTER_FAILED_SWITCH_MS * 2 ** view.failures);
|
|
422
|
+
view.failures++;
|
|
423
|
+
}
|
|
424
|
+
// A re-pull of the same publication gives a new receiver, and the SFU starts it on h.
|
|
425
|
+
#view({ publicationId, track }) {
|
|
426
|
+
let view = this.#views.get(publicationId);
|
|
427
|
+
if (!view) {
|
|
428
|
+
view = { policy: new ViewerLayerPolicy(), track, applied: "h", retryAt: 0, failures: 0 };
|
|
429
|
+
this.#views.set(publicationId, view);
|
|
430
|
+
} else if (view.track !== track) {
|
|
431
|
+
this.#reader.forget(view.track.id);
|
|
432
|
+
Object.assign(view, { track, applied: "h", retryAt: 0, failures: 0 });
|
|
433
|
+
}
|
|
434
|
+
return view;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
34
438
|
// src/media/observers.ts
|
|
35
439
|
function subscribeSnapshot(listeners, listener) {
|
|
36
440
|
listeners.add(listener);
|
|
@@ -176,6 +580,202 @@ function matchRemotePullTracks(publications, tracks) {
|
|
|
176
580
|
});
|
|
177
581
|
}
|
|
178
582
|
|
|
583
|
+
// src/media/call-stats.ts
|
|
584
|
+
var CALL_STATS_WINDOW_MS = 1e4;
|
|
585
|
+
var CALL_STATS_UPLOAD_SAMPLES = 6;
|
|
586
|
+
var CALL_STATS_MAX_BATCH = 30;
|
|
587
|
+
var CALL_STATS_MAX_WINDOW_MS = 6e4;
|
|
588
|
+
var CALL_STATS_CALL_CAP = 360;
|
|
589
|
+
var RID_ORDER = ["h", "m", "l"];
|
|
590
|
+
var MAX_ELAPSED_MS = 2147483647;
|
|
591
|
+
function createCallStatsCollector(options) {
|
|
592
|
+
const now = options.now ?? (() => Date.now());
|
|
593
|
+
const startedAt = now();
|
|
594
|
+
let buffer = [];
|
|
595
|
+
let produced = 0;
|
|
596
|
+
let uploading = null;
|
|
597
|
+
let baseline = null;
|
|
598
|
+
const gauges = { rtt: newGauges(), sendJitter: newGauges(), recvJitter: newGauges() };
|
|
599
|
+
let latest = null;
|
|
600
|
+
const closeWindow = (at) => {
|
|
601
|
+
if (!baseline || !latest) return;
|
|
602
|
+
const windowMs = at - baseline.at;
|
|
603
|
+
if (windowMs < 1e3) return;
|
|
604
|
+
const sample = summarise(latest, baseline, windowMs, at - startedAt, gauges);
|
|
605
|
+
baseline = readBaseline(latest, at);
|
|
606
|
+
resetGauges(gauges);
|
|
607
|
+
if (windowMs > CALL_STATS_MAX_WINDOW_MS || produced >= CALL_STATS_CALL_CAP) return;
|
|
608
|
+
produced++;
|
|
609
|
+
buffer.push(sample);
|
|
610
|
+
if (buffer.length > CALL_STATS_MAX_BATCH) buffer = buffer.slice(-CALL_STATS_MAX_BATCH);
|
|
611
|
+
};
|
|
612
|
+
const send = (upload) => {
|
|
613
|
+
if (uploading || buffer.length === 0) return uploading ?? Promise.resolve();
|
|
614
|
+
const samples = buffer;
|
|
615
|
+
buffer = [];
|
|
616
|
+
uploading = upload({ version: 1, samples }).catch(() => {
|
|
617
|
+
}).finally(() => {
|
|
618
|
+
uploading = null;
|
|
619
|
+
});
|
|
620
|
+
return uploading;
|
|
621
|
+
};
|
|
622
|
+
return {
|
|
623
|
+
observe(report) {
|
|
624
|
+
const at = now();
|
|
625
|
+
latest = report;
|
|
626
|
+
baseline ??= readBaseline(report, at);
|
|
627
|
+
addGauges(report, gauges);
|
|
628
|
+
if (at - baseline.at >= CALL_STATS_WINDOW_MS) closeWindow(at);
|
|
629
|
+
if (buffer.length >= CALL_STATS_UPLOAD_SAMPLES) void send(options.upload);
|
|
630
|
+
},
|
|
631
|
+
async flush(upload) {
|
|
632
|
+
closeWindow(now());
|
|
633
|
+
if (uploading) await uploading;
|
|
634
|
+
await send(upload);
|
|
635
|
+
},
|
|
636
|
+
reset() {
|
|
637
|
+
baseline = null;
|
|
638
|
+
latest = null;
|
|
639
|
+
resetGauges(gauges);
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function summarise(report, baseline, windowMs, elapsedMs, gauges) {
|
|
644
|
+
const delta = (type, field, kind) => sumDelta(report, baseline, type, field, kind);
|
|
645
|
+
const recvLost = delta("inbound-rtp", "packetsLost");
|
|
646
|
+
const pair = selectedPair(report);
|
|
647
|
+
const local = pair ? report.get(String(pair.localCandidateId)) : void 0;
|
|
648
|
+
const remote = pair ? report.get(String(pair.remoteCandidateId)) : void 0;
|
|
649
|
+
return {
|
|
650
|
+
elapsed_ms: Math.min(Math.round(elapsedMs), MAX_ELAPSED_MS),
|
|
651
|
+
window_ms: Math.round(windowMs),
|
|
652
|
+
connection_type: connectionType(local),
|
|
653
|
+
local_candidate_type: candidateType(local),
|
|
654
|
+
remote_candidate_type: candidateType(remote),
|
|
655
|
+
relay_protocol: relayProtocol(local),
|
|
656
|
+
rtt_ms: scaled(mean(gauges.rtt), 1e3, 6e4),
|
|
657
|
+
send_bitrate_kbps: scaled(delta("outbound-rtp", "bytesSent"), 8 / windowMs, 1e5),
|
|
658
|
+
send_loss_pct: lossPct(delta("remote-inbound-rtp", "packetsLost"), delta("outbound-rtp", "packetsSent")),
|
|
659
|
+
send_jitter_ms: scaled(mean(gauges.sendJitter), 1e3, 6e4),
|
|
660
|
+
send_layer: sendLayer(report, baseline),
|
|
661
|
+
recv_bitrate_kbps: scaled(delta("inbound-rtp", "bytesReceived"), 8 / windowMs, 1e5),
|
|
662
|
+
recv_loss_pct: lossPct(recvLost, sum(recvLost, delta("inbound-rtp", "packetsReceived"))),
|
|
663
|
+
recv_jitter_ms: scaled(mean(gauges.recvJitter), 1e3, 6e4),
|
|
664
|
+
recv_height: scaled(recvHeight(report), 1, 4320),
|
|
665
|
+
freeze_count: scaled(delta("inbound-rtp", "freezeCount", "video"), 1, 1e4),
|
|
666
|
+
freeze_ms: scaled(delta("inbound-rtp", "totalFreezesDuration", "video"), 1e3, 6e5)
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function scaled(value, factor, max) {
|
|
670
|
+
return value === null ? void 0 : clampInt(value * factor, max);
|
|
671
|
+
}
|
|
672
|
+
function stats(report, type, kind) {
|
|
673
|
+
const found = [];
|
|
674
|
+
report.forEach((stat) => {
|
|
675
|
+
if (stat.type !== type) return;
|
|
676
|
+
if (kind && (stat.kind ?? stat.mediaType) !== kind) return;
|
|
677
|
+
found.push(stat);
|
|
678
|
+
});
|
|
679
|
+
return found;
|
|
680
|
+
}
|
|
681
|
+
function readBaseline(report, at) {
|
|
682
|
+
const counters = /* @__PURE__ */ new Map();
|
|
683
|
+
report.forEach((stat) => {
|
|
684
|
+
for (const field of COUNTER_FIELDS) {
|
|
685
|
+
const value = stat[field];
|
|
686
|
+
if (typeof value === "number") counters.set(`${String(stat.id)}:${field}`, value);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
return { at, counters };
|
|
690
|
+
}
|
|
691
|
+
var COUNTER_FIELDS = ["packetsSent", "packetsLost", "packetsReceived", "bytesSent", "bytesReceived", "totalFreezesDuration", "freezeCount"];
|
|
692
|
+
function sumDelta(report, baseline, type, field, kind) {
|
|
693
|
+
let total = 0;
|
|
694
|
+
let seen = false;
|
|
695
|
+
for (const stat of stats(report, type, kind)) {
|
|
696
|
+
const current = stat[field];
|
|
697
|
+
const before = baseline.counters.get(`${String(stat.id)}:${field}`);
|
|
698
|
+
if (typeof current !== "number" || before === void 0) continue;
|
|
699
|
+
seen = true;
|
|
700
|
+
total += Math.max(0, current - before);
|
|
701
|
+
}
|
|
702
|
+
return seen ? total : null;
|
|
703
|
+
}
|
|
704
|
+
function lossPct(lost, total) {
|
|
705
|
+
if (lost === null || total === null || total <= 0) return void 0;
|
|
706
|
+
return Math.min(100, Math.round(lost / total * 1e4) / 100);
|
|
707
|
+
}
|
|
708
|
+
function sum(a, b) {
|
|
709
|
+
return a === null || b === null ? null : a + b;
|
|
710
|
+
}
|
|
711
|
+
function newGauges() {
|
|
712
|
+
return { sum: 0, count: 0 };
|
|
713
|
+
}
|
|
714
|
+
function resetGauges(gauges) {
|
|
715
|
+
for (const gauge of Object.values(gauges)) {
|
|
716
|
+
gauge.sum = 0;
|
|
717
|
+
gauge.count = 0;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function addGauge(gauge, value) {
|
|
721
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return;
|
|
722
|
+
gauge.sum += value;
|
|
723
|
+
gauge.count++;
|
|
724
|
+
}
|
|
725
|
+
function addGauges(report, gauges) {
|
|
726
|
+
const pair = selectedPair(report);
|
|
727
|
+
addGauge(gauges.rtt, pair?.currentRoundTripTime);
|
|
728
|
+
for (const stat of stats(report, "remote-inbound-rtp")) addGauge(gauges.sendJitter, stat.jitter);
|
|
729
|
+
for (const stat of stats(report, "inbound-rtp")) addGauge(gauges.recvJitter, stat.jitter);
|
|
730
|
+
}
|
|
731
|
+
function mean(gauge) {
|
|
732
|
+
return gauge.count === 0 ? null : gauge.sum / gauge.count;
|
|
733
|
+
}
|
|
734
|
+
function clampInt(value, max) {
|
|
735
|
+
return Math.min(max, Math.max(0, Math.round(value)));
|
|
736
|
+
}
|
|
737
|
+
function selectedPair(report) {
|
|
738
|
+
const transport = stats(report, "transport").find((stat) => typeof stat.selectedCandidatePairId === "string");
|
|
739
|
+
if (transport) return report.get(String(transport.selectedCandidatePairId));
|
|
740
|
+
return stats(report, "candidate-pair").find((stat) => stat.state === "succeeded" && (stat.selected === true || stat.nominated === true));
|
|
741
|
+
}
|
|
742
|
+
function connectionType(local) {
|
|
743
|
+
if (!local) return "unknown";
|
|
744
|
+
if (local.candidateType === "relay") return "turn";
|
|
745
|
+
if (local.protocol === "udp" || local.protocol === "tcp") return local.protocol;
|
|
746
|
+
return "unknown";
|
|
747
|
+
}
|
|
748
|
+
function candidateType(candidate) {
|
|
749
|
+
const type = candidate?.candidateType;
|
|
750
|
+
return type === "host" || type === "srflx" || type === "prflx" || type === "relay" ? type : void 0;
|
|
751
|
+
}
|
|
752
|
+
function relayProtocol(local) {
|
|
753
|
+
const protocol = local?.relayProtocol;
|
|
754
|
+
return protocol === "udp" || protocol === "tcp" || protocol === "tls" ? protocol : void 0;
|
|
755
|
+
}
|
|
756
|
+
function sendLayer(report, baseline) {
|
|
757
|
+
const active = /* @__PURE__ */ new Set();
|
|
758
|
+
for (const stat of stats(report, "outbound-rtp", "video")) {
|
|
759
|
+
const before = baseline.counters.get(`${String(stat.id)}:bytesSent`);
|
|
760
|
+
if (typeof stat.rid !== "string" || typeof stat.bytesSent !== "number" || before === void 0) continue;
|
|
761
|
+
if (stat.bytesSent > before) active.add(stat.rid);
|
|
762
|
+
}
|
|
763
|
+
return RID_ORDER.find((rid) => active.has(rid));
|
|
764
|
+
}
|
|
765
|
+
function recvHeight(report) {
|
|
766
|
+
let height = null;
|
|
767
|
+
for (const stat of stats(report, "inbound-rtp", "video")) {
|
|
768
|
+
if (typeof stat.frameHeight === "number") height = Math.max(height ?? 0, stat.frameHeight);
|
|
769
|
+
}
|
|
770
|
+
return height;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/media/ice-servers.ts
|
|
774
|
+
function iceServersFor(bootstrap) {
|
|
775
|
+
if (!bootstrap.iceServers || bootstrap.iceServers.length === 0) return [{ urls: bootstrap.stunServer }];
|
|
776
|
+
return bootstrap.iceServers.map(({ urls, username, credential }) => username === void 0 || credential === void 0 ? { urls: [...urls] } : { urls: [...urls], username, credential });
|
|
777
|
+
}
|
|
778
|
+
|
|
179
779
|
// src/media/failure-diagnostics.ts
|
|
180
780
|
var steps = /* @__PURE__ */ new WeakMap();
|
|
181
781
|
var fetchFailures = /* @__PURE__ */ new WeakSet();
|
|
@@ -234,10 +834,13 @@ function classifyMediaFailure(cause, fallbackStep) {
|
|
|
234
834
|
// src/media/client.ts
|
|
235
835
|
var EMPTY_LOCAL = Object.freeze([]);
|
|
236
836
|
var EMPTY_REMOTE = Object.freeze([]);
|
|
837
|
+
var EMPTY_RECEIVE_STALLS = Object.freeze([]);
|
|
237
838
|
var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
|
|
238
839
|
var CONNECTION_TIMEOUT_MS = 8e3;
|
|
239
840
|
var NEGOTIATION_TIMEOUT_MS = 35e3;
|
|
240
841
|
var PEER_OPERATION_TIMEOUT_MS = 8e3;
|
|
842
|
+
var VIEWER_LAYER_BATCH_SIZE = 16;
|
|
843
|
+
var VIEWER_LAYER_UPDATE_INTERVAL_MS = 5e3;
|
|
241
844
|
var CAPTURE_REUSE_WINDOW_MS = 2e4;
|
|
242
845
|
var CloudflareSFUClient = class {
|
|
243
846
|
#localListeners = /* @__PURE__ */ new Set();
|
|
@@ -265,6 +868,10 @@ var CloudflareSFUClient = class {
|
|
|
265
868
|
#cursor = null;
|
|
266
869
|
#remotePullIncomplete = false;
|
|
267
870
|
#remotePullRetries = /* @__PURE__ */ new Map();
|
|
871
|
+
#receiveProgress = new ReceiveStallRecovery();
|
|
872
|
+
#viewerLayerPending = /* @__PURE__ */ new Map();
|
|
873
|
+
#viewerLayerUpdating = false;
|
|
874
|
+
#viewerLayerUpdateAt = Number.NEGATIVE_INFINITY;
|
|
268
875
|
#remoteParticipantIds = null;
|
|
269
876
|
#remoteParticipantRevision = 0;
|
|
270
877
|
#subscribedPublicationIds = /* @__PURE__ */ new Set();
|
|
@@ -281,8 +888,10 @@ var CloudflareSFUClient = class {
|
|
|
281
888
|
#retirement = Promise.resolve();
|
|
282
889
|
#snapshot;
|
|
283
890
|
#started = false;
|
|
891
|
+
#receivingInitial = false;
|
|
284
892
|
#stopped = false;
|
|
285
893
|
#transport;
|
|
894
|
+
#callStats = createCallStatsCollector({ upload: async (stats2) => this.#transport?.uploadCallStats?.(stats2, false) });
|
|
286
895
|
constructor(options) {
|
|
287
896
|
validateClientOptions(options);
|
|
288
897
|
this.#participantId = options.participantId;
|
|
@@ -300,6 +909,7 @@ var CloudflareSFUClient = class {
|
|
|
300
909
|
cursor: null,
|
|
301
910
|
localTracks: EMPTY_LOCAL,
|
|
302
911
|
remoteTracks: EMPTY_REMOTE,
|
|
912
|
+
receiveStalls: this.#receiveProgress.observations,
|
|
303
913
|
failure: null
|
|
304
914
|
});
|
|
305
915
|
this.#observeConnection(this.#connection, this.#generation, this.#connectionEpoch);
|
|
@@ -320,7 +930,9 @@ var CloudflareSFUClient = class {
|
|
|
320
930
|
this.#remotePullIncomplete = true;
|
|
321
931
|
this.remotePublicationsChanged();
|
|
322
932
|
}
|
|
323
|
-
remotePublicationsChanged() {
|
|
933
|
+
remotePublicationsChanged(publications, projectionState = "available") {
|
|
934
|
+
if (projectionState === "unavailable") this.#receiveProgress.suspend();
|
|
935
|
+
else if (publications) this.#receiveProgress.expect(publications, this.#participantId);
|
|
324
936
|
if (this.#polling) {
|
|
325
937
|
this.#pollAfterCurrent = true;
|
|
326
938
|
return;
|
|
@@ -385,20 +997,30 @@ var CloudflareSFUClient = class {
|
|
|
385
997
|
this.#publishSnapshot();
|
|
386
998
|
this.#emitLocal();
|
|
387
999
|
}
|
|
388
|
-
async start(localMedia) {
|
|
1000
|
+
async start(localMedia, beforePublish) {
|
|
389
1001
|
if (this.#started) return;
|
|
390
1002
|
this.#requireActive();
|
|
391
|
-
const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
|
|
392
|
-
for (const track of tracks) this.prepareLocalTrack(track.kind === "audio" ? "microphone" : "camera", track);
|
|
393
1003
|
const generation = this.#generation;
|
|
394
1004
|
this.#setPhase("connecting", null);
|
|
1005
|
+
if (beforePublish) {
|
|
1006
|
+
this.#receivingInitial = true;
|
|
1007
|
+
this.#schedulePoll(0);
|
|
1008
|
+
try {
|
|
1009
|
+
localMedia = await beforePublish();
|
|
1010
|
+
} finally {
|
|
1011
|
+
this.#receivingInitial = false;
|
|
1012
|
+
}
|
|
1013
|
+
if (generation !== this.#generation || this.#stopped) return;
|
|
1014
|
+
}
|
|
1015
|
+
const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
|
|
1016
|
+
for (const track of tracks) this.prepareLocalTrack(track.kind === "audio" ? "microphone" : "camera", track);
|
|
395
1017
|
await this.#activatePreparedTracks(
|
|
396
1018
|
[...this.#localTracks.values()].filter((state) => state.source === "microphone" || state.source === "camera"),
|
|
397
1019
|
generation
|
|
398
1020
|
);
|
|
399
1021
|
}
|
|
400
1022
|
async refreshRemotePublications() {
|
|
401
|
-
if (!this.#started || this.#stopped || this.#polling) return;
|
|
1023
|
+
if (!this.#started && !this.#receivingInitial || this.#stopped || this.#polling) return;
|
|
402
1024
|
this.#polling = true;
|
|
403
1025
|
const generation = this.#generation;
|
|
404
1026
|
this.#pollFailedPull = false;
|
|
@@ -478,12 +1100,34 @@ var CloudflareSFUClient = class {
|
|
|
478
1100
|
return enabled ? Promise.resolve() : this.#retireOwnedPublications();
|
|
479
1101
|
}
|
|
480
1102
|
async closeForcedLocalPublication(source, cause = "forced_pause") {
|
|
1103
|
+
const connection = this.#connection;
|
|
1104
|
+
const generationAtClose = this.#generation;
|
|
481
1105
|
const sources = localSourceGroup(source);
|
|
482
1106
|
const ownsPublication = [...this.#ownedPublications].some((item) => sources.includes(item.state.source));
|
|
483
|
-
|
|
484
|
-
|
|
1107
|
+
const canRecover = ownsPublication && this.#replaceMediaConnection !== void 0;
|
|
1108
|
+
let failedRetirement;
|
|
1109
|
+
try {
|
|
1110
|
+
await this.setLocalSourceIntent(source, false, cause);
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
if (!canRecover) throw error;
|
|
1113
|
+
failedRetirement = this.#fenceFailedClose(error, connection, generationAtClose, cause);
|
|
1114
|
+
if (!failedRetirement) return;
|
|
1115
|
+
}
|
|
1116
|
+
if (!canRecover) return;
|
|
1117
|
+
await this.#restoreEnabledSourcesAfterClose(source);
|
|
1118
|
+
if (this.#retirement !== failedRetirement) await this.#retirement;
|
|
1119
|
+
}
|
|
1120
|
+
#fenceFailedClose(error, connection, generation, cause) {
|
|
1121
|
+
const failures = error instanceof AggregateError ? error.errors : [error];
|
|
1122
|
+
if (!failures.every((failure) => this.#isRetryableConnectionFailure(failure))) throw error;
|
|
1123
|
+
if (connection !== this.#connection || generation !== this.#generation) return;
|
|
1124
|
+
this.#retiredLocalConnection = connection;
|
|
1125
|
+
this.#retiredLocalCause = cause;
|
|
1126
|
+
return this.#retirement;
|
|
1127
|
+
}
|
|
1128
|
+
#restoreEnabledSourcesAfterClose(source) {
|
|
485
1129
|
const generation = this.#generation;
|
|
486
|
-
|
|
1130
|
+
return this.#serializeSDP(async () => {
|
|
487
1131
|
const existing = new Set([...this.#localTracks.values()].filter((state) => state.source !== source && state.providerPublicationId !== null));
|
|
488
1132
|
const states = await this.#prepareConnectionForPublication([], generation);
|
|
489
1133
|
await this.#publishPreparedTracksSerialized(
|
|
@@ -495,7 +1139,6 @@ var CloudflareSFUClient = class {
|
|
|
495
1139
|
this.#requireGeneration(generation);
|
|
496
1140
|
this.#setPhase("live", null);
|
|
497
1141
|
});
|
|
498
|
-
await this.#retirement;
|
|
499
1142
|
}
|
|
500
1143
|
async #retireUnavailableLocalPublication(state) {
|
|
501
1144
|
const publicationId = state.providerPublicationId;
|
|
@@ -641,6 +1284,9 @@ var CloudflareSFUClient = class {
|
|
|
641
1284
|
return;
|
|
642
1285
|
}
|
|
643
1286
|
this.#stopped = true;
|
|
1287
|
+
const transport = this.#transport;
|
|
1288
|
+
void this.#callStats.flush(async (stats2) => transport?.uploadCallStats?.(stats2, true));
|
|
1289
|
+
this.#receiveProgress.expect([], this.#participantId);
|
|
644
1290
|
this.#subscribedPublicationIds.clear();
|
|
645
1291
|
this.#generation++;
|
|
646
1292
|
this.#connectionEpoch++;
|
|
@@ -901,9 +1547,8 @@ var CloudflareSFUClient = class {
|
|
|
901
1547
|
if (cursor === null) return;
|
|
902
1548
|
const participantRevision = this.#remoteParticipantRevision;
|
|
903
1549
|
const desired = this.#desiredRemotePublications(authoritative, cursor);
|
|
904
|
-
const toPull = this.#pendingRemotePulls(desired);
|
|
905
1550
|
this.#pollFailedPull = true;
|
|
906
|
-
const pulled = await this.#pullWithRecovery(
|
|
1551
|
+
const pulled = await this.#pullWithRecovery(desired, cursor, generation);
|
|
907
1552
|
if (pulled === null) return;
|
|
908
1553
|
this.#requireGeneration(generation);
|
|
909
1554
|
const next = reconcileRemoteTracks(desired, pulled, this.#remoteTracks);
|
|
@@ -931,7 +1576,9 @@ var CloudflareSFUClient = class {
|
|
|
931
1576
|
}
|
|
932
1577
|
const now = Date.now();
|
|
933
1578
|
return [...desired].filter(([key, publication]) => {
|
|
934
|
-
if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId)
|
|
1579
|
+
if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) {
|
|
1580
|
+
if (!this.#receiveProgress.canRepull(publication.publicationId) && !(this.#replaceMediaConnection && this.#receiveProgress.canRebuild(publication.publicationId))) return false;
|
|
1581
|
+
}
|
|
935
1582
|
const remaining = (this.#remotePullRetries.get(publication.publicationId)?.retryAfter ?? 0) - now;
|
|
936
1583
|
return remaining <= 0;
|
|
937
1584
|
}).map(([, publication]) => publication);
|
|
@@ -960,7 +1607,11 @@ var CloudflareSFUClient = class {
|
|
|
960
1607
|
#canSubscribeToRemotePublication(publication) {
|
|
961
1608
|
return this.#remoteParticipantIds === null || this.#remoteParticipantIds.has(publication.participantId) || this.#subscribedPublicationIds.has(publication.publicationId);
|
|
962
1609
|
}
|
|
963
|
-
async #pullWithRecovery(
|
|
1610
|
+
async #pullWithRecovery(desired, cursor, generation) {
|
|
1611
|
+
const pending = this.#pendingRemotePulls(desired);
|
|
1612
|
+
const stalled = pending.filter((publication) => this.#receiveProgress.isStalled(publication.publicationId));
|
|
1613
|
+
if (stalled.length) return this.#recoverStalledRemotePublications(stalled, desired, cursor, generation);
|
|
1614
|
+
const publications = pending;
|
|
964
1615
|
try {
|
|
965
1616
|
return await this.#pull(publications, generation);
|
|
966
1617
|
} catch (error) {
|
|
@@ -972,6 +1623,46 @@ var CloudflareSFUClient = class {
|
|
|
972
1623
|
return this.#retryRemotePullOnReplacement(publications, cursor, generation);
|
|
973
1624
|
}
|
|
974
1625
|
}
|
|
1626
|
+
async #recoverStalledRemotePublications(stalled, desired, cursor, generation) {
|
|
1627
|
+
if (stalled.every((publication) => this.#receiveProgress.canRepull(publication.publicationId))) {
|
|
1628
|
+
try {
|
|
1629
|
+
return await this.#repullStalledRemotePublications(stalled, generation);
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
this.#requireGeneration(generation);
|
|
1632
|
+
if (!this.#replaceMediaConnection) throw error;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
if (!this.#replaceMediaConnection || !this.#receiveProgress.reserveRebuild()) return [];
|
|
1636
|
+
return this.#retryRemotePullOnReplacement([...desired.values()], cursor, generation, "receive_stalled");
|
|
1637
|
+
}
|
|
1638
|
+
async #repullStalledRemotePublications(stalled, generation) {
|
|
1639
|
+
for (const publication of stalled) this.#receiveProgress.reserveRepull(publication.publicationId);
|
|
1640
|
+
const retired = await this.#retireStalledReceivers(stalled, generation);
|
|
1641
|
+
if (!retired.length) return [];
|
|
1642
|
+
const pulled = await this.#pullBatch(retired, generation);
|
|
1643
|
+
if (pulled?.length !== retired.length) throw new CloudflareSFUError("Stalled receive subscriptions were not restored", "media_failed");
|
|
1644
|
+
for (const publication of retired) this.#receiveProgress.restarted(publication.publicationId);
|
|
1645
|
+
return pulled;
|
|
1646
|
+
}
|
|
1647
|
+
async #retireStalledReceivers(publications, generation) {
|
|
1648
|
+
return this.#serializeSDP(async () => {
|
|
1649
|
+
this.#requireGeneration(generation);
|
|
1650
|
+
const stalled = publications.filter((publication) => this.#receiveProgress.isStalled(publication.publicationId));
|
|
1651
|
+
if (!stalled.length) return [];
|
|
1652
|
+
const connection = this.#connection;
|
|
1653
|
+
const receivers = stalled.map((publication) => {
|
|
1654
|
+
const remote = [...this.#remoteTracks.values()].find((track) => track.publicationId === publication.publicationId);
|
|
1655
|
+
const transceiver = connection.getTransceivers().find((item) => item.receiver.track === remote?.track);
|
|
1656
|
+
if (!transceiver || transceiver.mid === null) throw new CloudflareSFUError("The stalled receiver has no negotiated MID", "media_failed");
|
|
1657
|
+
return { publication, transceiver, mid: transceiver.mid };
|
|
1658
|
+
});
|
|
1659
|
+
const connectionId = this.#bootstrap.connectionId;
|
|
1660
|
+
await this.#signal(() => this.#requireTransport().closeTracks({ connectionId, receiveOnly: true, force: true, tracks: receivers.map(({ publication, mid }) => ({ mid, source: publication.source, publicationId: publication.publicationId })) }), { generation, connectionId });
|
|
1661
|
+
this.#requireGeneration(generation);
|
|
1662
|
+
for (const { transceiver } of receivers) transceiver.stop();
|
|
1663
|
+
return stalled;
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
975
1666
|
#canReplaceConnectionAfterRemotePull(error, generation) {
|
|
976
1667
|
const gone = error instanceof CloudflareSFUError && (error.options.status === 404 || error.options.status === 410);
|
|
977
1668
|
return this.#isRetryableConnectionFailure(error) && (gone || !this.#hasLiveRemoteTracks()) && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
|
|
@@ -979,14 +1670,19 @@ var CloudflareSFUClient = class {
|
|
|
979
1670
|
#hasLiveRemoteTracks() {
|
|
980
1671
|
return [...this.#remoteTracks.values()].some((publication) => publication.track.readyState === "live");
|
|
981
1672
|
}
|
|
982
|
-
async #retryRemotePullOnReplacement(publications, cursor, generation) {
|
|
1673
|
+
async #retryRemotePullOnReplacement(publications, cursor, generation, cause = "remote_signaling_failure") {
|
|
983
1674
|
this.#remoteRecoveryGeneration = generation;
|
|
1675
|
+
const connection = this.#connection;
|
|
984
1676
|
try {
|
|
985
|
-
await this.#replaceMediaConnectionForRecovery(generation,
|
|
1677
|
+
await this.#replaceMediaConnectionForRecovery(generation, cause);
|
|
986
1678
|
await this.#republishPreparedTracksAfterRecovery(generation);
|
|
987
1679
|
return await this.#pull(publications, generation);
|
|
988
1680
|
} catch (error) {
|
|
989
1681
|
if (generation !== this.#generation || this.#stopped) throw error;
|
|
1682
|
+
if (cause === "receive_stalled" && connection === this.#connection) {
|
|
1683
|
+
this.#replacementAttemptedGeneration = null;
|
|
1684
|
+
for (const publication of publications) this.#backoffRemotePull(publication.publicationId, true);
|
|
1685
|
+
}
|
|
990
1686
|
this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
|
|
991
1687
|
throw error;
|
|
992
1688
|
}
|
|
@@ -1029,7 +1725,7 @@ var CloudflareSFUClient = class {
|
|
|
1029
1725
|
const retryAfter = retryableRemotePull(error) ? Date.now() + 15e3 : Number.POSITIVE_INFINITY;
|
|
1030
1726
|
for (const publication of publications) {
|
|
1031
1727
|
const failures = this.#remotePullRetries.get(publication.publicationId)?.failures ?? 0;
|
|
1032
|
-
this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures });
|
|
1728
|
+
this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures, receiveStalled: this.#remotePullRetries.get(publication.publicationId)?.receiveStalled });
|
|
1033
1729
|
}
|
|
1034
1730
|
}
|
|
1035
1731
|
async #pullBatch(publications, generation) {
|
|
@@ -1056,12 +1752,17 @@ var CloudflareSFUClient = class {
|
|
|
1056
1752
|
connection.addEventListener("track", onTrack);
|
|
1057
1753
|
try {
|
|
1058
1754
|
this.#requireGeneration(generation);
|
|
1059
|
-
const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), {
|
|
1755
|
+
const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), {
|
|
1756
|
+
generation,
|
|
1757
|
+
connectionId
|
|
1758
|
+
});
|
|
1060
1759
|
this.#requireGeneration(generation);
|
|
1061
1760
|
this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
|
|
1062
1761
|
const responseTracks = response.tracks ?? [];
|
|
1063
1762
|
const matched = matchRemotePullTracks(publications, responseTracks);
|
|
1064
|
-
for (const { publication } of matched)
|
|
1763
|
+
for (const { publication } of matched) {
|
|
1764
|
+
if (!this.#remotePullRetries.get(publication.publicationId)?.receiveStalled) this.#remotePullRetries.delete(publication.publicationId);
|
|
1765
|
+
}
|
|
1065
1766
|
await this.#completeRenegotiation(response, connection, connectionId, generation);
|
|
1066
1767
|
if (matched.length === 0) return [];
|
|
1067
1768
|
await this.#waitForConnection(connection, generation);
|
|
@@ -1091,13 +1792,82 @@ var CloudflareSFUClient = class {
|
|
|
1091
1792
|
for (const failure of failures) {
|
|
1092
1793
|
const publication = requested.get(`${failure.connectionId}\0${failure.trackName}`);
|
|
1093
1794
|
if (!publication) throw new CloudflareSFUError("Cloudflare SFU returned an unrequested remote track error", "invalid_publication");
|
|
1094
|
-
|
|
1095
|
-
const failureCount = Math.min(previous.failures + 1, 6);
|
|
1096
|
-
const delay = Math.min(15e3, 750 * 2 ** (failureCount - 1));
|
|
1097
|
-
this.#remotePullRetries.set(publication.publicationId, { retryAfter: Date.now() + delay, failures: failureCount });
|
|
1795
|
+
this.#backoffRemotePull(publication.publicationId);
|
|
1098
1796
|
this.#reportError(new CloudflareSFUError(`Cloudflare SFU remote track failed: ${failure.code}`, "media_failed", { providerCode: failure.code }));
|
|
1099
1797
|
}
|
|
1100
1798
|
}
|
|
1799
|
+
#backoffRemotePull(publicationId, receiveStalled = false) {
|
|
1800
|
+
const previous = this.#remotePullRetries.get(publicationId) ?? { retryAfter: 0, failures: 0 };
|
|
1801
|
+
const failures = Math.min(previous.failures + 1, 6);
|
|
1802
|
+
this.#remotePullRetries.set(publicationId, { retryAfter: Date.now() + Math.min(15e3, 750 * 2 ** (failures - 1)), failures, receiveStalled: receiveStalled || previous.receiveStalled });
|
|
1803
|
+
}
|
|
1804
|
+
#adaptViewerLayers(connection, report, selector, current) {
|
|
1805
|
+
if (!this.#transport?.updateTracks || !this.#canObserveReceiveProgress(connection)) return;
|
|
1806
|
+
const now = performance.now();
|
|
1807
|
+
const cameras = [...this.#remoteTracks.values()].filter(({ source, track }) => source === "camera" && track.readyState === "live");
|
|
1808
|
+
this.#queueViewerLayers(selector.sample(now, report, cameras));
|
|
1809
|
+
if (this.#viewerLayerUpdating || now < this.#viewerLayerUpdateAt) return;
|
|
1810
|
+
const batch = [...this.#viewerLayerPending.values()].flatMap((change) => {
|
|
1811
|
+
const mid = connection.getTransceivers().find((transceiver) => transceiver.receiver.track === change.track)?.mid;
|
|
1812
|
+
return mid === null || mid === void 0 ? [] : [{ change, mid }];
|
|
1813
|
+
}).slice(0, VIEWER_LAYER_BATCH_SIZE);
|
|
1814
|
+
if (!batch.length) return;
|
|
1815
|
+
this.#viewerLayerUpdating = true;
|
|
1816
|
+
this.#viewerLayerUpdateAt = now + VIEWER_LAYER_UPDATE_INTERVAL_MS;
|
|
1817
|
+
void this.#requestViewerLayers(this.#transport, batch).then((failed) => {
|
|
1818
|
+
this.#viewerLayerUpdating = false;
|
|
1819
|
+
if (!current()) return;
|
|
1820
|
+
for (const { change } of batch) {
|
|
1821
|
+
const pending = this.#viewerLayerPending.get(change.publicationId);
|
|
1822
|
+
if (pending?.track === change.track && pending.layer === change.layer) this.#viewerLayerPending.delete(change.publicationId);
|
|
1823
|
+
if (failed.has(change.publicationId)) selector.failed(change, performance.now());
|
|
1824
|
+
else selector.applied(change);
|
|
1825
|
+
}
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
#queueViewerLayers(changes) {
|
|
1829
|
+
const active = new Set(changes.map(({ publicationId }) => publicationId));
|
|
1830
|
+
for (const id of this.#viewerLayerPending.keys()) if (!active.has(id)) this.#viewerLayerPending.delete(id);
|
|
1831
|
+
for (const change of changes) this.#viewerLayerPending.set(change.publicationId, change);
|
|
1832
|
+
}
|
|
1833
|
+
// A layer switch is an optimization: when the SFU refuses it, the pulled layer keeps playing.
|
|
1834
|
+
async #requestViewerLayers(transport, batch) {
|
|
1835
|
+
try {
|
|
1836
|
+
const response = await transport.updateTracks?.({
|
|
1837
|
+
connectionId: this.#bootstrap.connectionId,
|
|
1838
|
+
tracks: batch.map(({ change, mid }) => {
|
|
1839
|
+
const { connectionId, trackName } = parseCloudflareSFUPublicationID(change.publicationId);
|
|
1840
|
+
return { location: "remote", mid, sessionId: connectionId, trackName, simulcast: { preferredRid: change.layer, priorityOrdering: "none", ridNotAvailable: "asciibetical" } };
|
|
1841
|
+
})
|
|
1842
|
+
});
|
|
1843
|
+
return new Set(
|
|
1844
|
+
batch.filter(({ change }) => {
|
|
1845
|
+
const { connectionId, trackName } = parseCloudflareSFUPublicationID(change.publicationId);
|
|
1846
|
+
return response?.trackErrors?.some((error) => error.connectionId === connectionId && error.trackName === trackName);
|
|
1847
|
+
}).map(({ change }) => change.publicationId)
|
|
1848
|
+
);
|
|
1849
|
+
} catch {
|
|
1850
|
+
return new Set(batch.map(({ change }) => change.publicationId));
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
#canObserveReceiveProgress(connection) {
|
|
1854
|
+
return this.#started && connectionIsLive(connection) && (typeof document === "undefined" || document.visibilityState !== "hidden");
|
|
1855
|
+
}
|
|
1856
|
+
#observeReceiveProgress(connection, report) {
|
|
1857
|
+
if (!this.#canObserveReceiveProgress(connection)) {
|
|
1858
|
+
this.#receiveProgress.clear();
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
const { stalled, advanced } = this.#receiveProgress.sample(performance.now(), connection, report, this.#remoteTracks.values());
|
|
1862
|
+
for (const publicationId of advanced) this.#remotePullRetries.delete(publicationId);
|
|
1863
|
+
this.#publishSnapshot();
|
|
1864
|
+
if (!stalled.some((id) => this.#receiveProgress.canRepull(id) || this.#replaceMediaConnection && this.#receiveProgress.canRebuild(id))) return;
|
|
1865
|
+
for (const publicationId of stalled) {
|
|
1866
|
+
if (!this.#remotePullRetries.get(publicationId)?.receiveStalled) this.#backoffRemotePull(publicationId, true);
|
|
1867
|
+
}
|
|
1868
|
+
this.#remotePullIncomplete = true;
|
|
1869
|
+
this.remotePublicationsChanged();
|
|
1870
|
+
}
|
|
1101
1871
|
async #completeRenegotiation(response, connection, connectionId, generation) {
|
|
1102
1872
|
if (!response.requiresImmediateRenegotiation) return;
|
|
1103
1873
|
await this.#applyProviderDescription(response, connection);
|
|
@@ -1193,6 +1963,7 @@ var CloudflareSFUClient = class {
|
|
|
1193
1963
|
let disposed = false;
|
|
1194
1964
|
let cameraTimer;
|
|
1195
1965
|
const cameraPolicy = new CameraUplinkPolicy();
|
|
1966
|
+
const viewerLayers = new ViewerLayerSelector();
|
|
1196
1967
|
let observedCamera;
|
|
1197
1968
|
const current = () => !disposed && generation === this.#generation && connectionEpoch === this.#connectionEpoch && !this.#stopped && connection.connectionState !== "failed" && connection.connectionState !== "closed";
|
|
1198
1969
|
const adaptCamera = async () => {
|
|
@@ -1203,14 +1974,14 @@ var CloudflareSFUClient = class {
|
|
|
1203
1974
|
cameraPolicy.reset();
|
|
1204
1975
|
observedCamera = state;
|
|
1205
1976
|
}
|
|
1206
|
-
const sender = this.#cameraSimulcastSender(state);
|
|
1207
|
-
if (!sender) {
|
|
1208
|
-
cameraPolicy.reset();
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
1977
|
const report = await connection.getStats();
|
|
1212
|
-
if (!current() ||
|
|
1213
|
-
|
|
1978
|
+
if (!current() || connection.signalingState !== "stable") return;
|
|
1979
|
+
this.#observeReceiveProgress(connection, report);
|
|
1980
|
+
this.#adaptViewerLayers(connection, report, viewerLayers, current);
|
|
1981
|
+
this.#callStats.observe(report);
|
|
1982
|
+
const sender = this.#cameraSimulcastSender(state);
|
|
1983
|
+
if (!sender) cameraPolicy.reset();
|
|
1984
|
+
else await this.#applyCameraUplink(sender, report, cameraPolicy);
|
|
1214
1985
|
} catch {
|
|
1215
1986
|
cameraPolicy.reset();
|
|
1216
1987
|
} finally {
|
|
@@ -1220,6 +1991,7 @@ var CloudflareSFUClient = class {
|
|
|
1220
1991
|
const dispose = () => {
|
|
1221
1992
|
if (disposed) return;
|
|
1222
1993
|
disposed = true;
|
|
1994
|
+
this.#viewerLayerPending.clear();
|
|
1223
1995
|
if (cameraTimer !== void 0) globalThis.clearTimeout(cameraTimer);
|
|
1224
1996
|
connection.removeEventListener("connectionstatechange", capture);
|
|
1225
1997
|
connection.removeEventListener("iceconnectionstatechange", capture);
|
|
@@ -1300,11 +2072,12 @@ var CloudflareSFUClient = class {
|
|
|
1300
2072
|
}
|
|
1301
2073
|
#createPeerConnection(bootstrap) {
|
|
1302
2074
|
const create = this.#peerConnectionFactory ?? ((configuration) => new RTCPeerConnection(configuration));
|
|
1303
|
-
return create({ iceServers:
|
|
2075
|
+
return create({ iceServers: iceServersFor(bootstrap), bundlePolicy: "max-bundle" });
|
|
1304
2076
|
}
|
|
1305
2077
|
#rebuildPeerConnection(bootstrap, generation, connectionEpoch) {
|
|
1306
2078
|
try {
|
|
1307
2079
|
this.#connection = this.#createPeerConnection(bootstrap);
|
|
2080
|
+
this.#callStats.reset();
|
|
1308
2081
|
this.#observeConnection(this.#connection, generation, connectionEpoch);
|
|
1309
2082
|
} catch (error) {
|
|
1310
2083
|
this.#setFailure(error, "media_failed");
|
|
@@ -1312,6 +2085,7 @@ var CloudflareSFUClient = class {
|
|
|
1312
2085
|
}
|
|
1313
2086
|
}
|
|
1314
2087
|
#disposeConnection(stopSenders) {
|
|
2088
|
+
this.#receiveProgress.replaced();
|
|
1315
2089
|
if (!stopSenders) this.#setPhase("recovering", null);
|
|
1316
2090
|
this.#disposeConnectionObservation?.();
|
|
1317
2091
|
this.#disposeConnectionObservation = void 0;
|
|
@@ -1349,7 +2123,7 @@ var CloudflareSFUClient = class {
|
|
|
1349
2123
|
this.#emitRemote();
|
|
1350
2124
|
}
|
|
1351
2125
|
#schedulePoll(delayMs = this.#pollIntervalMs) {
|
|
1352
|
-
if (this.#stopped || !this.#started || this.#pollTimer !== void 0) return;
|
|
2126
|
+
if (this.#stopped || !this.#started && !this.#receivingInitial || this.#pollTimer !== void 0) return;
|
|
1353
2127
|
this.#pollTimer = globalThis.setTimeout(async () => {
|
|
1354
2128
|
this.#pollTimer = void 0;
|
|
1355
2129
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
|
@@ -1408,6 +2182,7 @@ var CloudflareSFUClient = class {
|
|
|
1408
2182
|
})
|
|
1409
2183
|
) : EMPTY_LOCAL,
|
|
1410
2184
|
remoteTracks: this.#remoteTracks.size ? [...this.#remoteTracks.values()] : EMPTY_REMOTE,
|
|
2185
|
+
receiveStalls: this.#receiveProgress.observations,
|
|
1411
2186
|
failure
|
|
1412
2187
|
});
|
|
1413
2188
|
if (snapshotEqual(this.#snapshot, next)) return;
|
|
@@ -1532,7 +2307,7 @@ function snapshotEqual(left, right) {
|
|
|
1532
2307
|
return snapshotMetadataEqual(left, right) && left.localTracks.every((publication, index) => publicationEqual(publication, right.localTracks[index])) && left.remoteTracks.every((publication, index) => remotePublicationEqual(publication, right.remoteTracks[index]));
|
|
1533
2308
|
}
|
|
1534
2309
|
function snapshotMetadataEqual(left, right) {
|
|
1535
|
-
return connectionEqual(left.connection, right.connection) && cursorEqual(left.cursor, right.cursor) && failureEqual(left.failure, right.failure) && left.localTracks.length === right.localTracks.length && left.remoteTracks.length === right.remoteTracks.length;
|
|
2310
|
+
return (left.receiveStalls ?? EMPTY_RECEIVE_STALLS) === (right.receiveStalls ?? EMPTY_RECEIVE_STALLS) && connectionEqual(left.connection, right.connection) && cursorEqual(left.cursor, right.cursor) && failureEqual(left.failure, right.failure) && left.localTracks.length === right.localTracks.length && left.remoteTracks.length === right.remoteTracks.length;
|
|
1536
2311
|
}
|
|
1537
2312
|
function connectionEqual(left, right) {
|
|
1538
2313
|
return left.phase === right.phase && left.peerConnectionState === right.peerConnectionState && left.iceConnectionState === right.iceConnectionState;
|
|
@@ -1581,16 +2356,23 @@ function mediaTargetFailure(error) {
|
|
|
1581
2356
|
var SIGNALING_TIMEOUT_MS = 8e3;
|
|
1582
2357
|
var CREDENTIAL_TIMEOUT_MS = 7e3;
|
|
1583
2358
|
function createCloudflareSFUHTTPTransport(options) {
|
|
1584
|
-
const fetch = options.fetch
|
|
2359
|
+
const fetch = withRequestContext(options.fetch);
|
|
1585
2360
|
const credential = requireCredential(options);
|
|
2361
|
+
let lastToken = "";
|
|
1586
2362
|
const base = options.apiBaseURL.replace(/\/$/, "");
|
|
1587
2363
|
const mediaPath = `${base}/v1/tenants/${encodeURIComponent(options.tenantId)}/spaces/${encodeURIComponent(options.spaceId)}/episodes/${encodeURIComponent(options.episodeId)}/participants/${encodeURIComponent(options.participantId)}/media/sfu`;
|
|
2364
|
+
const statsPath = `${base}/v1/tenants/${encodeURIComponent(options.tenantId)}/call-stats`;
|
|
1588
2365
|
const timeoutMs = options.requestTimeoutMs ?? SIGNALING_TIMEOUT_MS;
|
|
1589
2366
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new CloudflareSFUError("The signaling deadline is invalid", "signaling_failed");
|
|
2367
|
+
const freshToken = async () => {
|
|
2368
|
+
const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
|
|
2369
|
+
if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
|
|
2370
|
+
lastToken = token;
|
|
2371
|
+
return token;
|
|
2372
|
+
};
|
|
1590
2373
|
const request = async (path, init) => {
|
|
1591
2374
|
try {
|
|
1592
|
-
const token = await
|
|
1593
|
-
if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
|
|
2375
|
+
const token = await freshToken();
|
|
1594
2376
|
return await withDeadline(timeoutMs, async (signal) => {
|
|
1595
2377
|
const startedAt = performance.now();
|
|
1596
2378
|
const response = await fetch(`${mediaPath}/${path}`, {
|
|
@@ -1640,15 +2422,34 @@ function createCloudflareSFUHTTPTransport(options) {
|
|
|
1640
2422
|
connection_id: input.connectionId,
|
|
1641
2423
|
session_description: input.sessionDescription,
|
|
1642
2424
|
tracks: input.tracks.map((track) => ({ mid: track.mid, source: track.source, publication_id: track.publicationId })),
|
|
1643
|
-
force: input.force
|
|
2425
|
+
force: input.force,
|
|
2426
|
+
...input.receiveOnly ? { receive_only: true } : {}
|
|
1644
2427
|
})
|
|
1645
2428
|
}),
|
|
2429
|
+
updateTracks: async (input) => request("tracks/update", {
|
|
2430
|
+
method: "PUT",
|
|
2431
|
+
body: JSON.stringify({ connection_id: input.connectionId, tracks: input.tracks })
|
|
2432
|
+
}),
|
|
1646
2433
|
renegotiate: async (input) => {
|
|
1647
2434
|
await request("renegotiate", {
|
|
1648
2435
|
method: "POST",
|
|
1649
2436
|
body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription })
|
|
1650
2437
|
});
|
|
1651
2438
|
},
|
|
2439
|
+
uploadCallStats: async (stats2, closing) => {
|
|
2440
|
+
const token = closing ? lastToken : await freshToken();
|
|
2441
|
+
if (!token) return;
|
|
2442
|
+
await withDeadline(timeoutMs, async (signal) => {
|
|
2443
|
+
const response = await fetch(statsPath, {
|
|
2444
|
+
method: "POST",
|
|
2445
|
+
signal,
|
|
2446
|
+
keepalive: true,
|
|
2447
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
2448
|
+
body: JSON.stringify(stats2)
|
|
2449
|
+
});
|
|
2450
|
+
if (!response.ok) throw new CloudflareSFUError(`Chalk call stats upload failed with HTTP ${response.status}`, "signaling_failed", { status: response.status });
|
|
2451
|
+
});
|
|
2452
|
+
},
|
|
1652
2453
|
listPublications: async () => {
|
|
1653
2454
|
const response = await request("publications");
|
|
1654
2455
|
return {
|
|
@@ -1713,6 +2514,7 @@ function describeMediaCaptureError(cause) {
|
|
|
1713
2514
|
}
|
|
1714
2515
|
|
|
1715
2516
|
export {
|
|
2517
|
+
setVideoRenderSize,
|
|
1716
2518
|
CloudflareSFUError,
|
|
1717
2519
|
parseCloudflareSFUPublicationID,
|
|
1718
2520
|
requireDescription,
|