@q9labsai/chalk-client 5.3.0 → 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.
@@ -1,3 +1,7 @@
1
+ import {
2
+ withRequestContext
3
+ } from "./chunk-N4IPKRS6.js";
4
+
1
5
  // src/media/receive-stall.ts
2
6
  var RECEIVE_STALL_WINDOW_MS = 7e3;
3
7
  var RECEIVE_STARTUP_GRACE_MS = 2e4;
@@ -46,6 +50,10 @@ var MAX_RECEIVE_OBSERVATIONS = 32;
46
50
  var ReceiveStallRecovery = class {
47
51
  #detector = new ReceiveStallDetector();
48
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();
49
57
  #identities = /* @__PURE__ */ new Map();
50
58
  #expected = /* @__PURE__ */ new Set();
51
59
  #screens = /* @__PURE__ */ new Set();
@@ -60,10 +68,12 @@ var ReceiveStallRecovery = class {
60
68
  this.#expected = new Set(enabled.flatMap((publication) => publication.publicationId ? [publication.publicationId] : []));
61
69
  this.#screens = new Set(enabled.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
62
70
  this.#pruneWithdrawn(publications);
71
+ this.#pruneAttempts(publications);
63
72
  for (const publication of this.#identities.values()) {
64
73
  if (this.#isExpected(publication)) continue;
65
74
  this.#detector.forget(publication.publicationId);
66
75
  this.#stalled.delete(publication.publicationId);
76
+ this.#awaiting.delete(publication.publicationId);
67
77
  }
68
78
  this.#active = [...this.#identities.values()].filter((publication) => this.#isExpected(publication)).map((publication) => publication.publicationId);
69
79
  }
@@ -81,17 +91,47 @@ var ReceiveStallRecovery = class {
81
91
  this.#identities.delete(key);
82
92
  }
83
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
+ }
84
98
  isStalled(publicationId) {
85
99
  return this.#stalled.has(publicationId);
86
100
  }
87
- canRebuild() {
88
- return this.#active.length > 0 && this.#active.every((id) => this.#stalled.has(id) && (this.#attempts.get(id) ?? 0) < MAX_RECEIVE_REBUILDS);
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));
89
117
  }
90
118
  reserveRebuild() {
91
119
  if (!this.canRebuild()) return false;
92
- for (const id of this.#active) this.#attempts.set(id, (this.#attempts.get(id) ?? 0) + 1);
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
+ }
93
124
  return true;
94
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
+ }
95
135
  clear() {
96
136
  this.#detector.clear();
97
137
  this.#stalled.clear();
@@ -103,36 +143,48 @@ var ReceiveStallRecovery = class {
103
143
  this.#active = active.map((publication) => publication.publicationId);
104
144
  const samples = active.flatMap((publication) => receivePacketSample(connection, report, publication) ?? []);
105
145
  const { stalled, advanced, resumed } = this.#detector.sample(now, samples);
106
- for (const id of resumed) this.#attempts.delete(id);
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
+ }
107
152
  const newlyStalled = stalled.filter((id) => !this.#stalled.has(id));
108
- this.#stalled = new Set(stalled);
153
+ const unrestored = this.#active.filter((id) => this.#awaiting.has(id) && !stalled.includes(id));
154
+ this.#stalled = /* @__PURE__ */ new Set([...stalled, ...unrestored]);
109
155
  if (newlyStalled.length) this.#record(now);
110
- return { stalled, advanced, newlyStalled };
156
+ return { stalled: [...stalled, ...unrestored], advanced, newlyStalled };
111
157
  }
112
158
  #isExpected(publication) {
113
159
  return publication.source === "screen_audio" ? this.#screens.has(publication.participantId) : this.#expected.has(publication.publicationId);
114
160
  }
115
161
  #rememberIdentity(publication) {
116
- const key = `${publication.participantId}\0${publication.source}`;
162
+ const key = sourceKey(publication);
117
163
  const previous = this.#identities.get(key);
118
164
  if (previous && previous.publicationId !== publication.publicationId) this.#forget(previous.publicationId);
119
165
  this.#identities.set(key, publication);
166
+ this.#sources.set(publication.publicationId, key);
120
167
  }
121
168
  #forget(id) {
122
169
  this.#detector.forget(id);
123
170
  this.#stalled.delete(id);
124
- this.#attempts.delete(id);
171
+ this.#awaiting.delete(id);
172
+ this.#repulled.delete(id);
173
+ this.#sources.delete(id);
125
174
  }
126
175
  #record(now) {
127
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 });
128
177
  this.#observations = Object.freeze([...this.#observations.slice(-(MAX_RECEIVE_OBSERVATIONS - 1)), observation]);
129
178
  }
130
179
  };
180
+ function sourceKey({ participantId, source }) {
181
+ return `${participantId}\0${source}`;
182
+ }
131
183
  function receivePacketSample(connection, report, publication) {
132
184
  const rows = [...report.values()].filter((row) => row.type === "inbound-rtp" && row.trackIdentifier === publication.track.id);
133
185
  if (rows.some((row) => !Number.isSafeInteger(row.packetsReceived) || row.packetsReceived < 0)) return;
134
186
  if (publication.track.kind === "audio" && !audioPacketCadenceKnown(connection, report, publication.track, rows)) return;
135
- return { publicationId: publication.publicationId, trackId: publication.track.id, packets: rows.reduce((sum, row) => sum + row.packetsReceived, 0) };
187
+ return { publicationId: publication.publicationId, trackId: publication.track.id, packets: rows.reduce((sum2, row) => sum2 + row.packetsReceived, 0) };
136
188
  }
137
189
  function audioPacketCadenceKnown(connection, report, track, rows) {
138
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 ?? [];
@@ -166,13 +218,223 @@ var CameraUplinkPolicy = class {
166
218
  this.#healthySamples = 0;
167
219
  }
168
220
  };
169
- function cameraUplinkBitrate(stats) {
170
- for (const stat of stats) {
221
+ function cameraUplinkBitrate(stats2) {
222
+ for (const stat of stats2) {
171
223
  if (stat.type === "candidate-pair" && stat.state === "succeeded" && (stat.nominated || stat.selected)) return stat.availableOutgoingBitrate;
172
224
  }
173
225
  return void 0;
174
226
  }
175
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
+
176
438
  // src/media/observers.ts
177
439
  function subscribeSnapshot(listeners, listener) {
178
440
  listeners.add(listener);
@@ -318,6 +580,202 @@ function matchRemotePullTracks(publications, tracks) {
318
580
  });
319
581
  }
320
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
+
321
779
  // src/media/failure-diagnostics.ts
322
780
  var steps = /* @__PURE__ */ new WeakMap();
323
781
  var fetchFailures = /* @__PURE__ */ new WeakSet();
@@ -381,6 +839,8 @@ var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
381
839
  var CONNECTION_TIMEOUT_MS = 8e3;
382
840
  var NEGOTIATION_TIMEOUT_MS = 35e3;
383
841
  var PEER_OPERATION_TIMEOUT_MS = 8e3;
842
+ var VIEWER_LAYER_BATCH_SIZE = 16;
843
+ var VIEWER_LAYER_UPDATE_INTERVAL_MS = 5e3;
384
844
  var CAPTURE_REUSE_WINDOW_MS = 2e4;
385
845
  var CloudflareSFUClient = class {
386
846
  #localListeners = /* @__PURE__ */ new Set();
@@ -409,6 +869,9 @@ var CloudflareSFUClient = class {
409
869
  #remotePullIncomplete = false;
410
870
  #remotePullRetries = /* @__PURE__ */ new Map();
411
871
  #receiveProgress = new ReceiveStallRecovery();
872
+ #viewerLayerPending = /* @__PURE__ */ new Map();
873
+ #viewerLayerUpdating = false;
874
+ #viewerLayerUpdateAt = Number.NEGATIVE_INFINITY;
412
875
  #remoteParticipantIds = null;
413
876
  #remoteParticipantRevision = 0;
414
877
  #subscribedPublicationIds = /* @__PURE__ */ new Set();
@@ -428,6 +891,7 @@ var CloudflareSFUClient = class {
428
891
  #receivingInitial = false;
429
892
  #stopped = false;
430
893
  #transport;
894
+ #callStats = createCallStatsCollector({ upload: async (stats2) => this.#transport?.uploadCallStats?.(stats2, false) });
431
895
  constructor(options) {
432
896
  validateClientOptions(options);
433
897
  this.#participantId = options.participantId;
@@ -820,6 +1284,8 @@ var CloudflareSFUClient = class {
820
1284
  return;
821
1285
  }
822
1286
  this.#stopped = true;
1287
+ const transport = this.#transport;
1288
+ void this.#callStats.flush(async (stats2) => transport?.uploadCallStats?.(stats2, true));
823
1289
  this.#receiveProgress.expect([], this.#participantId);
824
1290
  this.#subscribedPublicationIds.clear();
825
1291
  this.#generation++;
@@ -1111,7 +1577,7 @@ var CloudflareSFUClient = class {
1111
1577
  const now = Date.now();
1112
1578
  return [...desired].filter(([key, publication]) => {
1113
1579
  if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) {
1114
- if (!this.#receiveProgress.canRebuild() || !this.#replaceMediaConnection) return false;
1580
+ if (!this.#receiveProgress.canRepull(publication.publicationId) && !(this.#replaceMediaConnection && this.#receiveProgress.canRebuild(publication.publicationId))) return false;
1115
1581
  }
1116
1582
  const remaining = (this.#remotePullRetries.get(publication.publicationId)?.retryAfter ?? 0) - now;
1117
1583
  return remaining <= 0;
@@ -1143,12 +1609,9 @@ var CloudflareSFUClient = class {
1143
1609
  }
1144
1610
  async #pullWithRecovery(desired, cursor, generation) {
1145
1611
  const pending = this.#pendingRemotePulls(desired);
1146
- const receiveStalled = pending.some((publication) => this.#receiveProgress.isStalled(publication.publicationId));
1147
- const publications = receiveStalled ? [...desired.values()] : pending;
1148
- if (receiveStalled) {
1149
- if (!this.#replaceMediaConnection || !this.#receiveProgress.reserveRebuild()) return [];
1150
- return this.#retryRemotePullOnReplacement(publications, cursor, generation, "receive_stalled");
1151
- }
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;
1152
1615
  try {
1153
1616
  return await this.#pull(publications, generation);
1154
1617
  } catch (error) {
@@ -1160,6 +1623,46 @@ var CloudflareSFUClient = class {
1160
1623
  return this.#retryRemotePullOnReplacement(publications, cursor, generation);
1161
1624
  }
1162
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
+ }
1163
1666
  #canReplaceConnectionAfterRemotePull(error, generation) {
1164
1667
  const gone = error instanceof CloudflareSFUError && (error.options.status === 404 || error.options.status === 410);
1165
1668
  return this.#isRetryableConnectionFailure(error) && (gone || !this.#hasLiveRemoteTracks()) && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
@@ -1298,6 +1801,55 @@ var CloudflareSFUClient = class {
1298
1801
  const failures = Math.min(previous.failures + 1, 6);
1299
1802
  this.#remotePullRetries.set(publicationId, { retryAfter: Date.now() + Math.min(15e3, 750 * 2 ** (failures - 1)), failures, receiveStalled: receiveStalled || previous.receiveStalled });
1300
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
+ }
1301
1853
  #canObserveReceiveProgress(connection) {
1302
1854
  return this.#started && connectionIsLive(connection) && (typeof document === "undefined" || document.visibilityState !== "hidden");
1303
1855
  }
@@ -1309,7 +1861,7 @@ var CloudflareSFUClient = class {
1309
1861
  const { stalled, advanced } = this.#receiveProgress.sample(performance.now(), connection, report, this.#remoteTracks.values());
1310
1862
  for (const publicationId of advanced) this.#remotePullRetries.delete(publicationId);
1311
1863
  this.#publishSnapshot();
1312
- if (!this.#receiveProgress.canRebuild()) return;
1864
+ if (!stalled.some((id) => this.#receiveProgress.canRepull(id) || this.#replaceMediaConnection && this.#receiveProgress.canRebuild(id))) return;
1313
1865
  for (const publicationId of stalled) {
1314
1866
  if (!this.#remotePullRetries.get(publicationId)?.receiveStalled) this.#backoffRemotePull(publicationId, true);
1315
1867
  }
@@ -1411,6 +1963,7 @@ var CloudflareSFUClient = class {
1411
1963
  let disposed = false;
1412
1964
  let cameraTimer;
1413
1965
  const cameraPolicy = new CameraUplinkPolicy();
1966
+ const viewerLayers = new ViewerLayerSelector();
1414
1967
  let observedCamera;
1415
1968
  const current = () => !disposed && generation === this.#generation && connectionEpoch === this.#connectionEpoch && !this.#stopped && connection.connectionState !== "failed" && connection.connectionState !== "closed";
1416
1969
  const adaptCamera = async () => {
@@ -1424,6 +1977,8 @@ var CloudflareSFUClient = class {
1424
1977
  const report = await connection.getStats();
1425
1978
  if (!current() || connection.signalingState !== "stable") return;
1426
1979
  this.#observeReceiveProgress(connection, report);
1980
+ this.#adaptViewerLayers(connection, report, viewerLayers, current);
1981
+ this.#callStats.observe(report);
1427
1982
  const sender = this.#cameraSimulcastSender(state);
1428
1983
  if (!sender) cameraPolicy.reset();
1429
1984
  else await this.#applyCameraUplink(sender, report, cameraPolicy);
@@ -1436,6 +1991,7 @@ var CloudflareSFUClient = class {
1436
1991
  const dispose = () => {
1437
1992
  if (disposed) return;
1438
1993
  disposed = true;
1994
+ this.#viewerLayerPending.clear();
1439
1995
  if (cameraTimer !== void 0) globalThis.clearTimeout(cameraTimer);
1440
1996
  connection.removeEventListener("connectionstatechange", capture);
1441
1997
  connection.removeEventListener("iceconnectionstatechange", capture);
@@ -1516,11 +2072,12 @@ var CloudflareSFUClient = class {
1516
2072
  }
1517
2073
  #createPeerConnection(bootstrap) {
1518
2074
  const create = this.#peerConnectionFactory ?? ((configuration) => new RTCPeerConnection(configuration));
1519
- return create({ iceServers: [{ urls: bootstrap.stunServer }], bundlePolicy: "max-bundle" });
2075
+ return create({ iceServers: iceServersFor(bootstrap), bundlePolicy: "max-bundle" });
1520
2076
  }
1521
2077
  #rebuildPeerConnection(bootstrap, generation, connectionEpoch) {
1522
2078
  try {
1523
2079
  this.#connection = this.#createPeerConnection(bootstrap);
2080
+ this.#callStats.reset();
1524
2081
  this.#observeConnection(this.#connection, generation, connectionEpoch);
1525
2082
  } catch (error) {
1526
2083
  this.#setFailure(error, "media_failed");
@@ -1528,7 +2085,7 @@ var CloudflareSFUClient = class {
1528
2085
  }
1529
2086
  }
1530
2087
  #disposeConnection(stopSenders) {
1531
- this.#receiveProgress.clear();
2088
+ this.#receiveProgress.replaced();
1532
2089
  if (!stopSenders) this.#setPhase("recovering", null);
1533
2090
  this.#disposeConnectionObservation?.();
1534
2091
  this.#disposeConnectionObservation = void 0;
@@ -1799,16 +2356,23 @@ function mediaTargetFailure(error) {
1799
2356
  var SIGNALING_TIMEOUT_MS = 8e3;
1800
2357
  var CREDENTIAL_TIMEOUT_MS = 7e3;
1801
2358
  function createCloudflareSFUHTTPTransport(options) {
1802
- const fetch = options.fetch ?? globalThis.fetch;
2359
+ const fetch = withRequestContext(options.fetch);
1803
2360
  const credential = requireCredential(options);
2361
+ let lastToken = "";
1804
2362
  const base = options.apiBaseURL.replace(/\/$/, "");
1805
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`;
1806
2365
  const timeoutMs = options.requestTimeoutMs ?? SIGNALING_TIMEOUT_MS;
1807
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
+ };
1808
2373
  const request = async (path, init) => {
1809
2374
  try {
1810
- const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
1811
- if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
2375
+ const token = await freshToken();
1812
2376
  return await withDeadline(timeoutMs, async (signal) => {
1813
2377
  const startedAt = performance.now();
1814
2378
  const response = await fetch(`${mediaPath}/${path}`, {
@@ -1858,15 +2422,34 @@ function createCloudflareSFUHTTPTransport(options) {
1858
2422
  connection_id: input.connectionId,
1859
2423
  session_description: input.sessionDescription,
1860
2424
  tracks: input.tracks.map((track) => ({ mid: track.mid, source: track.source, publication_id: track.publicationId })),
1861
- force: input.force
2425
+ force: input.force,
2426
+ ...input.receiveOnly ? { receive_only: true } : {}
1862
2427
  })
1863
2428
  }),
2429
+ updateTracks: async (input) => request("tracks/update", {
2430
+ method: "PUT",
2431
+ body: JSON.stringify({ connection_id: input.connectionId, tracks: input.tracks })
2432
+ }),
1864
2433
  renegotiate: async (input) => {
1865
2434
  await request("renegotiate", {
1866
2435
  method: "POST",
1867
2436
  body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription })
1868
2437
  });
1869
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
+ },
1870
2453
  listPublications: async () => {
1871
2454
  const response = await request("publications");
1872
2455
  return {
@@ -1931,6 +2514,7 @@ function describeMediaCaptureError(cause) {
1931
2514
  }
1932
2515
 
1933
2516
  export {
2517
+ setVideoRenderSize,
1934
2518
  CloudflareSFUError,
1935
2519
  parseCloudflareSFUPublicationID,
1936
2520
  requireDescription,