@camstack/addon-pipeline 1.2.290 → 1.2.291
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/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-B64MIW2h.mjs → default-detection-model-DcTy-nJl.mjs} +1 -1
- package/dist/{default-detection-model-CN_Iu8NF.js → default-detection-model-jjdmKKr3.js} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-C4JqXCw-.mjs → dist-Bn6BQW8q.mjs} +487 -60
- package/dist/{dist-BIlnUdBn.js → dist-D7FonEUB.js} +487 -60
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-C-WfKJ6b.mjs → node-BN0Na3HN.mjs} +1 -1
- package/dist/{node-CVlJhqng.js → node-D3C1krEi.js} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-CJKKFNar.mjs → process-memory-CeZ8ms80.mjs} +1 -1
- package/dist/{process-memory-ZUucMC8Z.js → process-memory-DI4jrCtX.js} +1 -1
- package/dist/recorder/index.js +8 -2
- package/dist/recorder/index.mjs +8 -2
- package/dist/{segment-demux-js-4OAHyQ3t.mjs → segment-demux-js-CNPzlcyO.mjs} +1 -1
- package/dist/{segment-demux-js-D0mqN4RC.js → segment-demux-js-COooJ-t0.js} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-r5TULO-I.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BrD7TK3W.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-pSFdAaR0.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DhImOdbA.mjs +26 -0
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-DPdwl8-T.mjs → hostInit-CbSGtrCj.mjs} +2 -2
- package/dist/stream-broker/index.js +1056 -92
- package/dist/stream-broker/index.mjs +1056 -92
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D7VLO8g1.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-UqRrHdtw.mjs +0 -26
|
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
6
|
-
const require_dist = require("../dist-
|
|
7
|
-
const require_node = require("../node-
|
|
6
|
+
const require_dist = require("../dist-D7FonEUB.js");
|
|
7
|
+
const require_node = require("../node-D3C1krEi.js");
|
|
8
8
|
const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
|
|
9
9
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
10
10
|
const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
|
|
11
|
-
const require_segment_demux_js = require("../segment-demux-js-
|
|
11
|
+
const require_segment_demux_js = require("../segment-demux-js-COooJ-t0.js");
|
|
12
12
|
let node_os = require("node:os");
|
|
13
13
|
node_os = require_chunk.__toESM(node_os);
|
|
14
14
|
let node_crypto = require("node:crypto");
|
|
@@ -23181,6 +23181,246 @@ var H264Repacketizer = class {
|
|
|
23181
23181
|
}
|
|
23182
23182
|
};
|
|
23183
23183
|
//#endregion
|
|
23184
|
+
//#region src/stream-broker/webrtc/delivery-meter.ts
|
|
23185
|
+
/** The window the ladder judges delivery over: at least one GOP. */
|
|
23186
|
+
function deliveryWindowMs(idrIntervalMs, config) {
|
|
23187
|
+
const gop = idrIntervalMs !== null && Number.isFinite(idrIntervalMs) && idrIntervalMs > 0 ? idrIntervalMs : 0;
|
|
23188
|
+
return Math.min(config.maxWindowMs, Math.max(config.minWindowMs, gop));
|
|
23189
|
+
}
|
|
23190
|
+
/**
|
|
23191
|
+
* When a session's meter may start counting client reports. A session created
|
|
23192
|
+
* by a tier change starts after the change blackout (review I1): until the
|
|
23193
|
+
* client swaps its player onto the new PC (after the new stream's first
|
|
23194
|
+
* decode, 2–5 s), its reporter still reads the OLD PC — 0 kbps — and the
|
|
23195
|
+
* first report after the swap is 0 too; both fall inside the new session's
|
|
23196
|
+
* sends and read as a failed rung.
|
|
23197
|
+
*/
|
|
23198
|
+
function deliveryMeterStart(now, afterTierChange, waitAfterChangeMs) {
|
|
23199
|
+
return afterTierChange ? now + waitAfterChangeMs : now;
|
|
23200
|
+
}
|
|
23201
|
+
/**
|
|
23202
|
+
* Bucket width. 100 ms, and edges round to the NEAREST boundary, so a frame is
|
|
23203
|
+
* put on the wrong side of a window edge only when it was sent within 50 ms of
|
|
23204
|
+
* it — about one frame at 25 fps. Rounding edges inward instead dropped a whole
|
|
23205
|
+
* IDR that sat 160 ms inside a window and read a whole rung as 1.5× delivered.
|
|
23206
|
+
* 32 s of history is 320 slots.
|
|
23207
|
+
*/
|
|
23208
|
+
var SEND_BUCKET_MS = 100;
|
|
23209
|
+
/**
|
|
23210
|
+
* Bytes this session sent, bucketed by time. A fixed ring: `record` is on the
|
|
23211
|
+
* RTP send path, so it allocates nothing.
|
|
23212
|
+
*/
|
|
23213
|
+
var SendHistory = class {
|
|
23214
|
+
bytes;
|
|
23215
|
+
/** The bucket index each slot currently holds; -1 = empty. */
|
|
23216
|
+
ids;
|
|
23217
|
+
constructor(spanMs) {
|
|
23218
|
+
const slots = Math.max(1, Math.ceil(spanMs / SEND_BUCKET_MS));
|
|
23219
|
+
this.bytes = new Float64Array(slots);
|
|
23220
|
+
this.ids = new Float64Array(slots).fill(-1);
|
|
23221
|
+
}
|
|
23222
|
+
record(byteCount, now) {
|
|
23223
|
+
const id = Math.floor(now / SEND_BUCKET_MS);
|
|
23224
|
+
const slot = id % this.ids.length;
|
|
23225
|
+
if (this.ids[slot] !== id) {
|
|
23226
|
+
this.ids[slot] = id;
|
|
23227
|
+
this.bytes[slot] = 0;
|
|
23228
|
+
}
|
|
23229
|
+
this.bytes[slot] = (this.bytes[slot] ?? 0) + byteCount;
|
|
23230
|
+
}
|
|
23231
|
+
bitsBetween(fromMs, toMs) {
|
|
23232
|
+
const first = Math.round(fromMs / SEND_BUCKET_MS);
|
|
23233
|
+
const last = Math.round(toMs / SEND_BUCKET_MS) - 1;
|
|
23234
|
+
let bytes = 0;
|
|
23235
|
+
for (let id = first; id <= last; id += 1) {
|
|
23236
|
+
const slot = id % this.ids.length;
|
|
23237
|
+
if (this.ids[slot] === id) bytes += this.bytes[slot] ?? 0;
|
|
23238
|
+
}
|
|
23239
|
+
return bytes * 8;
|
|
23240
|
+
}
|
|
23241
|
+
};
|
|
23242
|
+
/**
|
|
23243
|
+
* The client's recent reports, and the ratio they imply against a
|
|
23244
|
+
* {@link SendHistoryReader}. One meter per adaptive session: `notBefore` is
|
|
23245
|
+
* the session's start, because the reports are DEVICE-scoped and a report
|
|
23246
|
+
* about time before this session describes the previous one (a re-offer).
|
|
23247
|
+
*/
|
|
23248
|
+
var DeliveryMeter = class {
|
|
23249
|
+
config;
|
|
23250
|
+
notBefore;
|
|
23251
|
+
reports = [];
|
|
23252
|
+
constructor(config, notBefore) {
|
|
23253
|
+
this.config = config;
|
|
23254
|
+
this.notBefore = notBefore;
|
|
23255
|
+
}
|
|
23256
|
+
/** Fold one client report. The same report read on several ticks counts once. */
|
|
23257
|
+
observe(kbps, endAt) {
|
|
23258
|
+
if (!Number.isFinite(kbps) || kbps < 0 || !Number.isFinite(endAt)) return;
|
|
23259
|
+
if (endAt - this.config.clientSampleSpanMs < this.notBefore) return;
|
|
23260
|
+
if (this.reports.some((r) => r.endAt === endAt)) return;
|
|
23261
|
+
const horizon = endAt - this.config.historySpanMs;
|
|
23262
|
+
this.reports = [...this.reports.filter((r) => r.endAt > horizon), {
|
|
23263
|
+
kbps,
|
|
23264
|
+
endAt
|
|
23265
|
+
}].sort((a, b) => a.endAt - b.endAt);
|
|
23266
|
+
}
|
|
23267
|
+
/**
|
|
23268
|
+
* Is the client reporting at all? A fresh report that does not yet cover a
|
|
23269
|
+
* whole window is "not measured YET" — different from a client that sends
|
|
23270
|
+
* no telemetry, and it must not be climbed on (a 10 %-delivery link would
|
|
23271
|
+
* otherwise promote in the seconds before its first window closes).
|
|
23272
|
+
*/
|
|
23273
|
+
isReporting(now) {
|
|
23274
|
+
const newest = this.reports.at(-1);
|
|
23275
|
+
return newest !== void 0 && now - newest.endAt <= this.config.maxClientSampleAgeMs;
|
|
23276
|
+
}
|
|
23277
|
+
/**
|
|
23278
|
+
* The newest report alone against the same 2 s of sends — the short window
|
|
23279
|
+
* a total freeze is judged on. Null when there is none, or it is stale.
|
|
23280
|
+
*/
|
|
23281
|
+
evaluateNewest(history, now) {
|
|
23282
|
+
const newest = this.reports.at(-1);
|
|
23283
|
+
if (newest === void 0 || now - newest.endAt > this.config.maxClientSampleAgeMs) return null;
|
|
23284
|
+
const span = this.config.clientSampleSpanMs;
|
|
23285
|
+
return {
|
|
23286
|
+
receivedKbps: newest.kbps,
|
|
23287
|
+
sentKbps: history.bitsBetween(newest.endAt - span, newest.endAt) / span,
|
|
23288
|
+
windowMs: span
|
|
23289
|
+
};
|
|
23290
|
+
}
|
|
23291
|
+
/**
|
|
23292
|
+
* Received vs sent over the newest reports that together cover `windowMs`,
|
|
23293
|
+
* or `null` when they do not (yet) — or the newest one is stale.
|
|
23294
|
+
*/
|
|
23295
|
+
evaluate(history, windowMs, now) {
|
|
23296
|
+
const newest = this.reports.at(-1);
|
|
23297
|
+
if (newest === void 0 || now - newest.endAt > this.config.maxClientSampleAgeMs) return null;
|
|
23298
|
+
const span = this.config.clientSampleSpanMs;
|
|
23299
|
+
let covered = 0;
|
|
23300
|
+
let receivedBits = 0;
|
|
23301
|
+
let sentBits = 0;
|
|
23302
|
+
for (let i = this.reports.length - 1; i >= 0 && covered < windowMs; i -= 1) {
|
|
23303
|
+
const r = this.reports[i];
|
|
23304
|
+
if (r === void 0 || now - (r.endAt - span) > this.config.historySpanMs) break;
|
|
23305
|
+
covered += span;
|
|
23306
|
+
receivedBits += r.kbps * span;
|
|
23307
|
+
sentBits += history.bitsBetween(r.endAt - span, r.endAt);
|
|
23308
|
+
}
|
|
23309
|
+
if (covered < windowMs) return null;
|
|
23310
|
+
return {
|
|
23311
|
+
receivedKbps: receivedBits / covered,
|
|
23312
|
+
sentKbps: sentBits / covered,
|
|
23313
|
+
windowMs: covered
|
|
23314
|
+
};
|
|
23315
|
+
}
|
|
23316
|
+
};
|
|
23317
|
+
//#endregion
|
|
23318
|
+
//#region src/stream-broker/webrtc/adaptive-config.ts
|
|
23319
|
+
/** How often the adaptive controller re-evaluates the ladder (ms). */
|
|
23320
|
+
var ADAPTIVE_TICK_MS = 2e3;
|
|
23321
|
+
/** Ladder tuning shared by every adaptive session. */
|
|
23322
|
+
var ADAPTIVE_CONFIG = {
|
|
23323
|
+
analyzeWindowMs: 15e3,
|
|
23324
|
+
minFailPercent: .05,
|
|
23325
|
+
waitAfterResetMs: 8e3,
|
|
23326
|
+
upgradeHealthyMs: 4e3,
|
|
23327
|
+
upgradeHealthyGrowthFactor: 3,
|
|
23328
|
+
maxProgressiveUpgradeHealthyMs: 3e4,
|
|
23329
|
+
minTimeBetweenChangesMs: 4e3,
|
|
23330
|
+
maxUpgradeBackoffMs: 9e5,
|
|
23331
|
+
stallDeliveryRatio: .25,
|
|
23332
|
+
recoverDeliveryRatio: .5,
|
|
23333
|
+
freezeDeliveryRatio: .1,
|
|
23334
|
+
freezeMinSentKbps: 16,
|
|
23335
|
+
climbDeliveryRatio: .9,
|
|
23336
|
+
stallConfirmMs: 6e3,
|
|
23337
|
+
waitAfterChangeMs: 8e3,
|
|
23338
|
+
climbCapacityMargin: 1.2,
|
|
23339
|
+
tryClimbDeliveryRatio: .95,
|
|
23340
|
+
tryClimbMaxLossRatio: .01,
|
|
23341
|
+
tryBackoffBaseMs: 3e4,
|
|
23342
|
+
tryBackoffMaxMs: 6e5,
|
|
23343
|
+
trialMs: 2e4,
|
|
23344
|
+
trialMaxMs: 4e4,
|
|
23345
|
+
trialRevertRatio: .9,
|
|
23346
|
+
trialRevertConsecutive: 2
|
|
23347
|
+
};
|
|
23348
|
+
/** How the delivery window is built from the client's reports. */
|
|
23349
|
+
var ADAPTIVE_DELIVERY_CONFIG = {
|
|
23350
|
+
clientSampleSpanMs: 2e3,
|
|
23351
|
+
minWindowMs: 2e3,
|
|
23352
|
+
maxWindowMs: 16e3,
|
|
23353
|
+
maxClientSampleAgeMs: 1e4,
|
|
23354
|
+
historySpanMs: 32e3
|
|
23355
|
+
};
|
|
23356
|
+
//#endregion
|
|
23357
|
+
//#region src/stream-broker/webrtc/climb-capacity.ts
|
|
23358
|
+
/** The client ladder's thresholds (`DOWNLINK_MID_MBPS`, `DOWNLINK_HIGH_MBPS`), kbps. */
|
|
23359
|
+
var UNMEASURED_RUNG_BAR_KBPS = {
|
|
23360
|
+
low: 0,
|
|
23361
|
+
mid: 2500,
|
|
23362
|
+
high: 8e3
|
|
23363
|
+
};
|
|
23364
|
+
/** The kbps the next rung needs; `(1800 × 1.2)` or the client threshold. */
|
|
23365
|
+
function capacityBarKbps(tier, rungKbps, margin) {
|
|
23366
|
+
return rungKbps !== null && rungKbps > 0 ? rungKbps * margin : UNMEASURED_RUNG_BAR_KBPS[tier];
|
|
23367
|
+
}
|
|
23368
|
+
function known(v) {
|
|
23369
|
+
return v !== null && Number.isFinite(v) && v > 0;
|
|
23370
|
+
}
|
|
23371
|
+
function judgeCapacity(evidence, nextTier, nextRungKbps, margin) {
|
|
23372
|
+
const neededKbps = capacityBarKbps(nextTier, nextRungKbps, margin);
|
|
23373
|
+
const { rembKbps, probeKbps, probeFresh } = evidence;
|
|
23374
|
+
if (known(probeKbps) && probeFresh && probeKbps >= neededKbps) return {
|
|
23375
|
+
kind: "sufficient",
|
|
23376
|
+
source: "probe",
|
|
23377
|
+
kbps: probeKbps,
|
|
23378
|
+
neededKbps
|
|
23379
|
+
};
|
|
23380
|
+
if (known(rembKbps) && rembKbps >= neededKbps) return {
|
|
23381
|
+
kind: "sufficient",
|
|
23382
|
+
source: "remb",
|
|
23383
|
+
kbps: rembKbps,
|
|
23384
|
+
neededKbps
|
|
23385
|
+
};
|
|
23386
|
+
if (known(probeKbps) && probeKbps < neededKbps) return {
|
|
23387
|
+
kind: "probe-short",
|
|
23388
|
+
kbps: probeKbps,
|
|
23389
|
+
neededKbps
|
|
23390
|
+
};
|
|
23391
|
+
return {
|
|
23392
|
+
kind: "none",
|
|
23393
|
+
bestKbps: known(rembKbps) ? rembKbps : null,
|
|
23394
|
+
neededKbps
|
|
23395
|
+
};
|
|
23396
|
+
}
|
|
23397
|
+
/**
|
|
23398
|
+
* REMB as it can be trusted. werift stores the LAST received estimate on the
|
|
23399
|
+
* sender and never ages it, so a peer that stopped sending REMB (backgrounded,
|
|
23400
|
+
* gone) would vouch for room indefinitely (review m5). Chrome re-estimates
|
|
23401
|
+
* about once a second and the value moves; an estimate unchanged for
|
|
23402
|
+
* `staleMs` (30 000 ms — fifteen ticks) is treated as no estimate at all.
|
|
23403
|
+
*/
|
|
23404
|
+
var RembWatch = class {
|
|
23405
|
+
staleMs;
|
|
23406
|
+
last = null;
|
|
23407
|
+
changedAt = 0;
|
|
23408
|
+
constructor(staleMs) {
|
|
23409
|
+
this.staleMs = staleMs;
|
|
23410
|
+
}
|
|
23411
|
+
read(kbps, now) {
|
|
23412
|
+
if (kbps === null) {
|
|
23413
|
+
this.last = null;
|
|
23414
|
+
return null;
|
|
23415
|
+
}
|
|
23416
|
+
if (kbps !== this.last) {
|
|
23417
|
+
this.last = kbps;
|
|
23418
|
+
this.changedAt = now;
|
|
23419
|
+
}
|
|
23420
|
+
return now - this.changedAt > this.staleMs ? null : kbps;
|
|
23421
|
+
}
|
|
23422
|
+
};
|
|
23423
|
+
//#endregion
|
|
23184
23424
|
//#region src/stream-broker/webrtc/transcode-egress-leg.ts
|
|
23185
23425
|
/**
|
|
23186
23426
|
* The request a browser session makes. Structured only: the primitive's schema
|
|
@@ -23295,6 +23535,8 @@ function selectAudioClock(mimeType) {
|
|
|
23295
23535
|
*
|
|
23296
23536
|
* Uses werift (optional peer dependency) for server-side WebRTC.
|
|
23297
23537
|
*/
|
|
23538
|
+
/** A REMB unchanged this long is no estimate (`RembWatch`). */
|
|
23539
|
+
var REMB_STALE_MS = 3e4;
|
|
23298
23540
|
var _werift;
|
|
23299
23541
|
async function loadWerift() {
|
|
23300
23542
|
if (_werift) return _werift;
|
|
@@ -24816,6 +25058,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
24816
25058
|
this.maybeRebaseRtp(pkt.header.sequenceNumber, pkt.header.timestamp);
|
|
24817
25059
|
try {
|
|
24818
25060
|
this.videoSender.sendRtp(pkt);
|
|
25061
|
+
this.videoSendHistory.record(pkt.payload.length, Date.now());
|
|
24819
25062
|
this.lastVideoRtpTs = pkt.header.timestamp >>> 0;
|
|
24820
25063
|
if (this.firstVideoRtpTs === null) {
|
|
24821
25064
|
this.firstVideoRtpTs = this.lastVideoRtpTs;
|
|
@@ -25387,6 +25630,26 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
25387
25630
|
get rtpPacketsSentTotal() {
|
|
25388
25631
|
return this.rtpPacketsSent;
|
|
25389
25632
|
}
|
|
25633
|
+
/**
|
|
25634
|
+
* Video RTP PAYLOAD bytes this session sent, bucketed by time — the "sent"
|
|
25635
|
+
* half of the adaptive ladder's delivery ratio (D635). Payload, not wire
|
|
25636
|
+
* size, because the client's `bytesReceived` excludes RTP headers.
|
|
25637
|
+
*/
|
|
25638
|
+
videoSendHistory = new SendHistory(ADAPTIVE_DELIVERY_CONFIG.historySpanMs);
|
|
25639
|
+
get sentVideoHistory() {
|
|
25640
|
+
return this.videoSendHistory;
|
|
25641
|
+
}
|
|
25642
|
+
/**
|
|
25643
|
+
* The receiver's bandwidth estimate (REMB) in kbps, or `null` when the peer
|
|
25644
|
+
* has sent none. werift stores the last REMB on the sender; `-1`/absent in
|
|
25645
|
+
* the loss line means the same "no estimate", which is not "zero".
|
|
25646
|
+
*/
|
|
25647
|
+
receiverEstimateKbps(now) {
|
|
25648
|
+
const raw = this.videoSender?.receiverEstimatedMaxBitrate;
|
|
25649
|
+
return this.rembWatch.read(typeof raw === "bigint" && raw > 0n ? Number(raw) / 1e3 : null, now);
|
|
25650
|
+
}
|
|
25651
|
+
/** Ages the REMB werift keeps forever — see `RembWatch`. */
|
|
25652
|
+
rembWatch = new RembWatch(REMB_STALE_MS);
|
|
25390
25653
|
rtpLogCounter = 0;
|
|
25391
25654
|
/** One-shot guards for the "first video/audio RTP" debug markers. */
|
|
25392
25655
|
firstVideoRtpLogged = false;
|
|
@@ -25709,6 +25972,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
25709
25972
|
} });
|
|
25710
25973
|
}
|
|
25711
25974
|
this.videoSender.sendRtp(pkt);
|
|
25975
|
+
this.videoSendHistory.record(payload.length, Date.now());
|
|
25712
25976
|
this.rtpPacketsSent++;
|
|
25713
25977
|
this.logFirstVideoRtp("writeVideoNals", codec);
|
|
25714
25978
|
if (this.debug && (this.rtpPacketsSent === 1 || this.rtpPacketsSent % 500 === 0)) this.logger.info("RTP packets sent", { meta: {
|
|
@@ -26176,6 +26440,42 @@ var LossTracker = class {
|
|
|
26176
26440
|
//#endregion
|
|
26177
26441
|
//#region src/stream-broker/webrtc/stall-tracker.ts
|
|
26178
26442
|
/**
|
|
26443
|
+
* The one place the device-scoped telemetry is judged ownable. With more than
|
|
26444
|
+
* one adaptive session of the device, the record may be another viewer's:
|
|
26445
|
+
* delivery is unknown ("no telemetry" semantics — the loss path judges) and
|
|
26446
|
+
* the sample says so, so the ladder refuses a try-climb on it.
|
|
26447
|
+
*/
|
|
26448
|
+
function buildStallSample(input) {
|
|
26449
|
+
if (input.adaptiveSessionsOnDevice > 1) return {
|
|
26450
|
+
delivery: null,
|
|
26451
|
+
recent: null,
|
|
26452
|
+
clientReporting: false,
|
|
26453
|
+
telemetryShared: true,
|
|
26454
|
+
serverSending: input.serverSending
|
|
26455
|
+
};
|
|
26456
|
+
return {
|
|
26457
|
+
delivery: input.delivery,
|
|
26458
|
+
recent: input.recent,
|
|
26459
|
+
clientReporting: input.clientReporting,
|
|
26460
|
+
telemetryShared: false,
|
|
26461
|
+
serverSending: input.serverSending
|
|
26462
|
+
};
|
|
26463
|
+
}
|
|
26464
|
+
var UNKNOWN_EVIDENCE = {
|
|
26465
|
+
clientKbps: null,
|
|
26466
|
+
sentKbps: null,
|
|
26467
|
+
deliveryRatio: null,
|
|
26468
|
+
deliveryWindowMs: null,
|
|
26469
|
+
stalledForMs: 0,
|
|
26470
|
+
serverSending: false
|
|
26471
|
+
};
|
|
26472
|
+
function ratioOf(delivery) {
|
|
26473
|
+
if (delivery === null) return null;
|
|
26474
|
+
const { receivedKbps, sentKbps } = delivery;
|
|
26475
|
+
if (!Number.isFinite(receivedKbps) || !Number.isFinite(sentKbps) || sentKbps <= 0) return null;
|
|
26476
|
+
return receivedKbps / sentKbps;
|
|
26477
|
+
}
|
|
26478
|
+
/**
|
|
26179
26479
|
* Windowed "the media stopped arriving" detector. Pure state machine: no
|
|
26180
26480
|
* clock of its own, no side effects — `observe` is the only mutator and every
|
|
26181
26481
|
* decision is a function of the samples it was given.
|
|
@@ -26185,11 +26485,9 @@ var StallTracker = class {
|
|
|
26185
26485
|
/** When the current uninterrupted stall began; null when not stalling. */
|
|
26186
26486
|
stallStartedAt = null;
|
|
26187
26487
|
blackoutUntil = 0;
|
|
26188
|
-
lastEvidence =
|
|
26189
|
-
|
|
26190
|
-
|
|
26191
|
-
serverSending: false
|
|
26192
|
-
};
|
|
26488
|
+
lastEvidence = UNKNOWN_EVIDENCE;
|
|
26489
|
+
clientReporting = false;
|
|
26490
|
+
telemetryShared = false;
|
|
26193
26491
|
constructor(config) {
|
|
26194
26492
|
this.config = config;
|
|
26195
26493
|
}
|
|
@@ -26202,38 +26500,36 @@ var StallTracker = class {
|
|
|
26202
26500
|
* Fold one evaluation into the window. Returns nothing; ask `isStalled`.
|
|
26203
26501
|
*
|
|
26204
26502
|
* Three outcomes, and the middle one is the reason this is not a simple
|
|
26205
|
-
* boolean: a
|
|
26206
|
-
* below `
|
|
26207
|
-
* (or unknown, or
|
|
26208
|
-
*
|
|
26503
|
+
* boolean: a ratio at or above `recoverDeliveryRatio` CLEARS the stall, a
|
|
26504
|
+
* ratio below `stallDeliveryRatio` with the server sending starts or extends
|
|
26505
|
+
* it, and anything between the two (or unknown, or nothing sent) HOLDS
|
|
26506
|
+
* whatever the window already believed.
|
|
26209
26507
|
*/
|
|
26210
26508
|
observe(sample, now) {
|
|
26509
|
+
const ratio = ratioOf(sample.delivery);
|
|
26510
|
+
const measured = (stalledForMs) => ({
|
|
26511
|
+
clientKbps: sample.delivery?.receivedKbps ?? null,
|
|
26512
|
+
sentKbps: sample.delivery?.sentKbps ?? null,
|
|
26513
|
+
deliveryRatio: ratio,
|
|
26514
|
+
deliveryWindowMs: sample.delivery?.windowMs ?? null,
|
|
26515
|
+
stalledForMs,
|
|
26516
|
+
serverSending: sample.serverSending
|
|
26517
|
+
});
|
|
26518
|
+
this.clientReporting = sample.clientReporting;
|
|
26519
|
+
this.telemetryShared = sample.telemetryShared;
|
|
26211
26520
|
if (now < this.blackoutUntil) {
|
|
26212
26521
|
this.stallStartedAt = null;
|
|
26213
|
-
this.lastEvidence =
|
|
26214
|
-
clientKbps: sample.clientKbps,
|
|
26215
|
-
stalledForMs: 0,
|
|
26216
|
-
serverSending: sample.serverSending
|
|
26217
|
-
};
|
|
26522
|
+
this.lastEvidence = measured(0);
|
|
26218
26523
|
return;
|
|
26219
26524
|
}
|
|
26220
|
-
const
|
|
26221
|
-
const
|
|
26222
|
-
|
|
26223
|
-
|
|
26224
|
-
|
|
26225
|
-
|
|
26226
|
-
|
|
26227
|
-
|
|
26228
|
-
return;
|
|
26229
|
-
}
|
|
26230
|
-
if (kbps >= this.config.recoverKbpsFloor) this.stallStartedAt = null;
|
|
26231
|
-
else if (kbps < this.config.stallKbpsFloor && sample.serverSending) this.stallStartedAt ??= now;
|
|
26232
|
-
this.lastEvidence = {
|
|
26233
|
-
clientKbps: kbps,
|
|
26234
|
-
stalledForMs: this.stalledForMs(now),
|
|
26235
|
-
serverSending: sample.serverSending
|
|
26236
|
-
};
|
|
26525
|
+
const recent = sample.recent !== null && sample.recent.sentKbps >= this.config.freezeMinSentKbps ? ratioOf(sample.recent) : null;
|
|
26526
|
+
const frozen = recent !== null && recent < this.config.freezeDeliveryRatio;
|
|
26527
|
+
const recovered = recent !== null && recent >= this.config.recoverDeliveryRatio;
|
|
26528
|
+
const starved = ratio !== null && ratio < this.config.stallDeliveryRatio && !recovered;
|
|
26529
|
+
const current = recent ?? ratio;
|
|
26530
|
+
if ((frozen || starved) && sample.serverSending) this.stallStartedAt ??= now;
|
|
26531
|
+
else if (current !== null && current >= this.config.recoverDeliveryRatio) this.stallStartedAt = null;
|
|
26532
|
+
this.lastEvidence = measured(this.stalledForMs(now));
|
|
26237
26533
|
}
|
|
26238
26534
|
stalledForMs(now) {
|
|
26239
26535
|
return this.stallStartedAt === null ? 0 : now - this.stallStartedAt;
|
|
@@ -26244,25 +26540,42 @@ var StallTracker = class {
|
|
|
26244
26540
|
return this.stalledForMs(now) >= this.config.stallConfirmMs;
|
|
26245
26541
|
}
|
|
26246
26542
|
/**
|
|
26247
|
-
* May the ladder PROMOTE on this evaluation?
|
|
26543
|
+
* May the ladder PROMOTE on this evaluation, as far as the transport knows?
|
|
26248
26544
|
*
|
|
26249
|
-
*
|
|
26250
|
-
*
|
|
26251
|
-
*
|
|
26252
|
-
*
|
|
26253
|
-
* reports almost no loss. Same doctrine as `LossTracker.hasEvidence` — a
|
|
26254
|
-
* climb needs POSITIVE evidence, not the absence of a complaint.
|
|
26545
|
+
* No while a stall is building, and no while the rung we are on is not
|
|
26546
|
+
* landing (nearly) whole — promoting there spends bandwidth the session has
|
|
26547
|
+
* just been shown not to have, and the loss path cannot object: a stream
|
|
26548
|
+
* delivering almost nothing reports almost no loss.
|
|
26255
26549
|
*
|
|
26256
|
-
* Unknown
|
|
26550
|
+
* Unknown refuses only while the client IS reporting — the window is simply
|
|
26551
|
+
* not covered yet, and climbing then would promote a link measured at 10 %
|
|
26552
|
+
* a few seconds later. A deployment with no client telemetry at all must
|
|
26257
26553
|
* behave exactly as it did before this tracker existed; the loss path's own
|
|
26258
26554
|
* `hasEvidence` is the gate there.
|
|
26259
26555
|
*/
|
|
26260
|
-
|
|
26261
|
-
if (now < this.blackoutUntil) return true;
|
|
26262
|
-
if (this.stallStartedAt !== null) return
|
|
26263
|
-
|
|
26264
|
-
|
|
26265
|
-
|
|
26556
|
+
climbSafety(now) {
|
|
26557
|
+
if (now < this.blackoutUntil) return { safe: true };
|
|
26558
|
+
if (this.stallStartedAt !== null) return {
|
|
26559
|
+
safe: false,
|
|
26560
|
+
reason: "stall-building"
|
|
26561
|
+
};
|
|
26562
|
+
const ratio = this.lastEvidence.deliveryRatio;
|
|
26563
|
+
if (ratio === null) return this.clientReporting ? {
|
|
26564
|
+
safe: false,
|
|
26565
|
+
reason: "delivery"
|
|
26566
|
+
} : { safe: true };
|
|
26567
|
+
return ratio >= this.config.climbDeliveryRatio ? { safe: true } : {
|
|
26568
|
+
safe: false,
|
|
26569
|
+
reason: "delivery"
|
|
26570
|
+
};
|
|
26571
|
+
}
|
|
26572
|
+
/** Was the device's client record possibly another viewer's at the last evaluation? */
|
|
26573
|
+
get isTelemetryShared() {
|
|
26574
|
+
return this.telemetryShared;
|
|
26575
|
+
}
|
|
26576
|
+
/** Was the client reporting at the last evaluation (even if not yet covering a window)? */
|
|
26577
|
+
get isClientReporting() {
|
|
26578
|
+
return this.clientReporting;
|
|
26266
26579
|
}
|
|
26267
26580
|
/** What the last `observe` measured — for the switch's log line. */
|
|
26268
26581
|
evidence(now) {
|
|
@@ -26273,9 +26586,142 @@ var StallTracker = class {
|
|
|
26273
26586
|
}
|
|
26274
26587
|
};
|
|
26275
26588
|
//#endregion
|
|
26589
|
+
//#region src/stream-broker/webrtc/rtt-stability.ts
|
|
26590
|
+
/** 16 s — the longest delivery window, so both halves of a try look at the same span. */
|
|
26591
|
+
var RTT_WINDOW_MS = 16e3;
|
|
26592
|
+
var MIN_SAMPLES = 3;
|
|
26593
|
+
var MIN_SPREAD_MS = 20;
|
|
26594
|
+
var SPREAD_FRACTION = .5;
|
|
26595
|
+
var RttWindow = class {
|
|
26596
|
+
samples = [];
|
|
26597
|
+
observe(rttMs, now) {
|
|
26598
|
+
if (rttMs === null || !Number.isFinite(rttMs) || rttMs <= 0) return;
|
|
26599
|
+
this.samples = [...this.samples.filter((s) => now - s.at <= RTT_WINDOW_MS), {
|
|
26600
|
+
rttMs,
|
|
26601
|
+
at: now
|
|
26602
|
+
}];
|
|
26603
|
+
}
|
|
26604
|
+
stability(now) {
|
|
26605
|
+
const recent = this.samples.filter((s) => now - s.at <= RTT_WINDOW_MS);
|
|
26606
|
+
if (recent.length < MIN_SAMPLES) return "unknown";
|
|
26607
|
+
const values = recent.map((s) => s.rttMs);
|
|
26608
|
+
const min = Math.min(...values);
|
|
26609
|
+
return Math.max(...values) - min <= Math.max(MIN_SPREAD_MS, min * SPREAD_FRACTION) ? "stable" : "unstable";
|
|
26610
|
+
}
|
|
26611
|
+
};
|
|
26612
|
+
//#endregion
|
|
26613
|
+
//#region src/stream-broker/webrtc/try-climb.ts
|
|
26614
|
+
var NO_TRY_HISTORY = {
|
|
26615
|
+
failures: 0,
|
|
26616
|
+
notBefore: 0,
|
|
26617
|
+
lastRevert: null,
|
|
26618
|
+
trialFrom: null
|
|
26619
|
+
};
|
|
26620
|
+
/** Backoff after the Nth consecutive failed try: base × 2^(N−1), capped. */
|
|
26621
|
+
function tryBackoffMs(failures, baseMs, maxMs) {
|
|
26622
|
+
if (failures <= 0) return 0;
|
|
26623
|
+
return Math.min(baseMs * 2 ** (failures - 1), maxMs);
|
|
26624
|
+
}
|
|
26625
|
+
/** Watches one tried rung through its first window. Pure: fed, never reads a clock. */
|
|
26626
|
+
var TrialWatch = class {
|
|
26627
|
+
from;
|
|
26628
|
+
config;
|
|
26629
|
+
startedAt = null;
|
|
26630
|
+
lowStreak = 0;
|
|
26631
|
+
measured = 0;
|
|
26632
|
+
constructor(from, config) {
|
|
26633
|
+
this.from = from;
|
|
26634
|
+
this.config = config;
|
|
26635
|
+
}
|
|
26636
|
+
/**
|
|
26637
|
+
* Start the trial over: it must be judged afresh on live media. Called when
|
|
26638
|
+
* recorded playback takes the track (review m14) — time off live must never
|
|
26639
|
+
* count toward keeping a rung that was never measured.
|
|
26640
|
+
*/
|
|
26641
|
+
restart() {
|
|
26642
|
+
this.startedAt = null;
|
|
26643
|
+
this.lowStreak = 0;
|
|
26644
|
+
this.measured = 0;
|
|
26645
|
+
}
|
|
26646
|
+
judge(now, input) {
|
|
26647
|
+
this.startedAt ??= now;
|
|
26648
|
+
if (input.stalled || input.lossFailing) return {
|
|
26649
|
+
kind: "revert",
|
|
26650
|
+
deliveryRatio: input.deliveryRatio,
|
|
26651
|
+
stalled: input.stalled
|
|
26652
|
+
};
|
|
26653
|
+
if (input.deliveryRatio !== null) {
|
|
26654
|
+
this.measured += 1;
|
|
26655
|
+
this.lowStreak = input.deliveryRatio < this.config.trialRevertRatio ? this.lowStreak + 1 : 0;
|
|
26656
|
+
if (this.lowStreak >= this.config.trialRevertConsecutive) return {
|
|
26657
|
+
kind: "revert",
|
|
26658
|
+
deliveryRatio: input.deliveryRatio,
|
|
26659
|
+
stalled: false
|
|
26660
|
+
};
|
|
26661
|
+
}
|
|
26662
|
+
const forMs = now - this.startedAt;
|
|
26663
|
+
const judged = this.measured >= this.config.trialRevertConsecutive || !input.clientReporting;
|
|
26664
|
+
if (forMs >= this.config.trialMs && judged || forMs >= this.config.trialMaxMs) return { kind: "kept" };
|
|
26665
|
+
return {
|
|
26666
|
+
kind: "running",
|
|
26667
|
+
forMs
|
|
26668
|
+
};
|
|
26669
|
+
}
|
|
26670
|
+
};
|
|
26671
|
+
/**
|
|
26672
|
+
* The try-climb memory per DEVICE on this broker, with a TTL (review I4).
|
|
26673
|
+
* The per-session intent survives only a re-offer; without this, every new
|
|
26674
|
+
* viewer session of a camera whose next rung keeps failing started again at
|
|
26675
|
+
* try #1. The trial itself (`trialFrom`) never carries to a new session.
|
|
26676
|
+
*/
|
|
26677
|
+
var DeviceTryMemory = class {
|
|
26678
|
+
ttlMs;
|
|
26679
|
+
entries = /* @__PURE__ */ new Map();
|
|
26680
|
+
/** @param ttlMs forget a device not refreshed for this long — 600 000 ms, the backoff cap. */
|
|
26681
|
+
constructor(ttlMs) {
|
|
26682
|
+
this.ttlMs = ttlMs;
|
|
26683
|
+
}
|
|
26684
|
+
set(deviceId, carry, now) {
|
|
26685
|
+
this.entries.set(deviceId, {
|
|
26686
|
+
carry,
|
|
26687
|
+
at: now
|
|
26688
|
+
});
|
|
26689
|
+
}
|
|
26690
|
+
get(deviceId, now) {
|
|
26691
|
+
const entry = this.entries.get(deviceId);
|
|
26692
|
+
if (entry === void 0) return null;
|
|
26693
|
+
if (now - entry.at > this.ttlMs) {
|
|
26694
|
+
this.entries.delete(deviceId);
|
|
26695
|
+
return null;
|
|
26696
|
+
}
|
|
26697
|
+
return {
|
|
26698
|
+
...entry.carry,
|
|
26699
|
+
trialFrom: null
|
|
26700
|
+
};
|
|
26701
|
+
}
|
|
26702
|
+
};
|
|
26703
|
+
//#endregion
|
|
26276
26704
|
//#region src/stream-broker/webrtc/adaptive-controller.ts
|
|
26277
26705
|
var TIER_ORDER = require_dist.CAM_PROFILE_ORDER;
|
|
26278
26706
|
/**
|
|
26707
|
+
* The cause as the wire (`WebrtcLadderDecision.cause`) can carry it. The wire
|
|
26708
|
+
* enum predates the try-climb, and widening it would make a server built
|
|
26709
|
+
* before it reject this broker's debug answer (the router validates output).
|
|
26710
|
+
* A trial revert is reported as what undelivered bytes are — `stall` when the
|
|
26711
|
+
* stall confirmed, else `loss` — and the climb hold carries the exact account
|
|
26712
|
+
* ("tried mid, reverted: delivery 0.60").
|
|
26713
|
+
*/
|
|
26714
|
+
function wireLadderCause(decision, stallConfirmMs) {
|
|
26715
|
+
if (decision.cause !== "trial-revert") return { cause: decision.cause };
|
|
26716
|
+
return {
|
|
26717
|
+
cause: decision.stall.stalledForMs >= stallConfirmMs ? "stall" : "loss",
|
|
26718
|
+
causeDetail: "trial-revert"
|
|
26719
|
+
};
|
|
26720
|
+
}
|
|
26721
|
+
function positiveOrNull(v) {
|
|
26722
|
+
return v !== null && Number.isFinite(v) && v > 0 ? v : null;
|
|
26723
|
+
}
|
|
26724
|
+
/**
|
|
26279
26725
|
* Ladder state machine for an adaptive WebRTC session. Fed loss samples
|
|
26280
26726
|
* (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
|
|
26281
26727
|
* Downgrade order: tier high→mid→low, then MTU reduction at low, then
|
|
@@ -26289,7 +26735,16 @@ var TIER_ORDER = require_dist.CAM_PROFILE_ORDER;
|
|
|
26289
26735
|
* a session that has produced no RTCP at all is not "healthy", it is
|
|
26290
26736
|
* silent, and climbing on silence spends bandwidth never shown to exist;
|
|
26291
26737
|
* 3. the target tier's re-attempt backoff, which doubles per measured
|
|
26292
|
-
* failure of that tier in this session
|
|
26738
|
+
* failure of that tier in this session;
|
|
26739
|
+
* 4. the transport showing the CURRENT rung landing whole
|
|
26740
|
+
* (`StallTracker.climbSafety`, a delivery ratio over one GOP — D635);
|
|
26741
|
+
* 5. capacity: evidence (REMB or the viewer's probe) clearing the next
|
|
26742
|
+
* rung's bar confirms the climb; a MEASURED probe below it holds; with no
|
|
26743
|
+
* evidence either way the ladder TRIES the rung on a clean path (delivery
|
|
26744
|
+
* ≥ 0.95, loss < 1 %, RTT stable) and reverts within the rung's first
|
|
26745
|
+
* window if it does not land, backing off 30 s → 10 min (D635, round 2).
|
|
26746
|
+
*
|
|
26747
|
+
* Every tick that does not promote leaves a {@link ClimbHold} naming why.
|
|
26293
26748
|
*/
|
|
26294
26749
|
var AdaptiveController = class {
|
|
26295
26750
|
opts;
|
|
@@ -26310,6 +26765,16 @@ var AdaptiveController = class {
|
|
|
26310
26765
|
armed = false;
|
|
26311
26766
|
failures;
|
|
26312
26767
|
upgradeCount;
|
|
26768
|
+
capacity = {
|
|
26769
|
+
rembKbps: null,
|
|
26770
|
+
probeKbps: null,
|
|
26771
|
+
probeFresh: false
|
|
26772
|
+
};
|
|
26773
|
+
rtt = new RttWindow();
|
|
26774
|
+
tryCarry;
|
|
26775
|
+
/** Non-null while the current rung is a trial. */
|
|
26776
|
+
trial;
|
|
26777
|
+
hold = null;
|
|
26313
26778
|
constructor(opts) {
|
|
26314
26779
|
this.opts = opts;
|
|
26315
26780
|
this.tracker = new LossTracker(opts.config);
|
|
@@ -26317,6 +26782,16 @@ var AdaptiveController = class {
|
|
|
26317
26782
|
this.tier = opts.initialTier;
|
|
26318
26783
|
this.failures = new Map(opts.priorFailures ?? []);
|
|
26319
26784
|
this.upgradeCount = Math.max(0, opts.priorUpgradeCount ?? 0);
|
|
26785
|
+
const carried = opts.tryClimb ?? NO_TRY_HISTORY;
|
|
26786
|
+
this.tryCarry = carried.trialFrom === opts.initialTier ? {
|
|
26787
|
+
...carried,
|
|
26788
|
+
trialFrom: null
|
|
26789
|
+
} : carried;
|
|
26790
|
+
this.trial = this.tryCarry.trialFrom !== null ? new TrialWatch(this.tryCarry.trialFrom, opts.config) : null;
|
|
26791
|
+
}
|
|
26792
|
+
/** What the NEXT controller of this viewer session must inherit about tries. */
|
|
26793
|
+
get tryClimbCarry() {
|
|
26794
|
+
return this.tryCarry;
|
|
26320
26795
|
}
|
|
26321
26796
|
get currentTier() {
|
|
26322
26797
|
return this.tier;
|
|
@@ -26349,6 +26824,27 @@ var AdaptiveController = class {
|
|
|
26349
26824
|
this.stalls.observe(sample, now);
|
|
26350
26825
|
}
|
|
26351
26826
|
/**
|
|
26827
|
+
* The capacity witnesses, kbps — `null` when unknown: the receiver's REMB
|
|
26828
|
+
* (capped by what is sent, so evidence only FOR room) and the viewer's
|
|
26829
|
+
* downlink probe for this session (a path measurement, evidence both ways).
|
|
26830
|
+
* See `climb-capacity.ts`.
|
|
26831
|
+
*/
|
|
26832
|
+
onCapacity(evidence) {
|
|
26833
|
+
this.capacity = {
|
|
26834
|
+
rembKbps: positiveOrNull(evidence.rembKbps),
|
|
26835
|
+
probeKbps: positiveOrNull(evidence.probeKbps),
|
|
26836
|
+
probeFresh: evidence.probeFresh
|
|
26837
|
+
};
|
|
26838
|
+
}
|
|
26839
|
+
/** The client's round-trip time, ms; null/0 = unknown. One of a try's conditions. */
|
|
26840
|
+
onRtt(rttMs, now) {
|
|
26841
|
+
this.rtt.observe(rttMs, now);
|
|
26842
|
+
}
|
|
26843
|
+
/** Why the ladder did not climb on the last evaluation; null = at the top, or it just moved. */
|
|
26844
|
+
get climbHold() {
|
|
26845
|
+
return this.hold;
|
|
26846
|
+
}
|
|
26847
|
+
/**
|
|
26352
26848
|
* Freeze the ladder for this evaluation instead of ticking it.
|
|
26353
26849
|
*
|
|
26354
26850
|
* Called while the session's track is NOT carrying the live source — D42
|
|
@@ -26369,6 +26865,8 @@ var AdaptiveController = class {
|
|
|
26369
26865
|
this.lastChangeAt = now;
|
|
26370
26866
|
}
|
|
26371
26867
|
this.healthySince = now;
|
|
26868
|
+
this.trial?.restart();
|
|
26869
|
+
this.setHold("suspended", now);
|
|
26372
26870
|
this.stalls.markChanged(now);
|
|
26373
26871
|
}
|
|
26374
26872
|
tick(now) {
|
|
@@ -26377,26 +26875,132 @@ var AdaptiveController = class {
|
|
|
26377
26875
|
this.healthySince = now;
|
|
26378
26876
|
this.lastChangeAt = now;
|
|
26379
26877
|
}
|
|
26878
|
+
if (this.trial !== null && this.judgeTrial(now)) return;
|
|
26380
26879
|
if (this.stalls.isStalled(now)) {
|
|
26381
26880
|
this.healthySince = now;
|
|
26881
|
+
this.hold = null;
|
|
26382
26882
|
this.handleFailing(now, "stall");
|
|
26383
26883
|
return;
|
|
26384
26884
|
}
|
|
26385
26885
|
if (this.tracker.isFailing(now)) {
|
|
26386
26886
|
this.healthySince = now;
|
|
26887
|
+
this.hold = null;
|
|
26387
26888
|
this.handleFailing(now, "loss");
|
|
26388
26889
|
return;
|
|
26389
26890
|
}
|
|
26390
|
-
|
|
26891
|
+
const safety = this.stalls.climbSafety(now);
|
|
26892
|
+
if (!safety.safe) {
|
|
26391
26893
|
this.healthySince = now;
|
|
26894
|
+
this.setHold(safety.reason, now);
|
|
26392
26895
|
return;
|
|
26393
26896
|
}
|
|
26394
26897
|
if (!this.tracker.hasEvidence(now)) {
|
|
26395
26898
|
this.healthySince = now;
|
|
26899
|
+
this.setHold("no-evidence", now);
|
|
26396
26900
|
return;
|
|
26397
26901
|
}
|
|
26398
26902
|
this.handleHealthy(now);
|
|
26399
26903
|
}
|
|
26904
|
+
nextTier() {
|
|
26905
|
+
const idx = TIER_ORDER.indexOf(this.tier);
|
|
26906
|
+
return idx <= 0 ? null : TIER_ORDER[idx - 1] ?? null;
|
|
26907
|
+
}
|
|
26908
|
+
/**
|
|
26909
|
+
* Record why this evaluation did not climb. At the top rung there is nothing
|
|
26910
|
+
* to hold — except a `trial`, which names the rung being tried.
|
|
26911
|
+
*/
|
|
26912
|
+
setHold(code, now, extra = {}) {
|
|
26913
|
+
const next = code === "trial" ? this.tier : this.nextTier();
|
|
26914
|
+
if (next === null) {
|
|
26915
|
+
this.hold = null;
|
|
26916
|
+
return;
|
|
26917
|
+
}
|
|
26918
|
+
const ev = this.stalls.evidence(now);
|
|
26919
|
+
const clockRunning = code === "warming" || code === "cooldown" || code === "capacity" || code === "try-unclean" || code === "try-backoff";
|
|
26920
|
+
const nextTierKbps = this.opts.expectedTierKbps?.(next) ?? null;
|
|
26921
|
+
this.hold = {
|
|
26922
|
+
code,
|
|
26923
|
+
nextTier: next,
|
|
26924
|
+
deliveryRatio: ev.deliveryRatio,
|
|
26925
|
+
deliveryWindowMs: ev.deliveryWindowMs,
|
|
26926
|
+
climbDeliveryRatio: this.opts.config.climbDeliveryRatio,
|
|
26927
|
+
capacityKbps: this.capacity.rembKbps,
|
|
26928
|
+
probeKbps: this.capacity.probeKbps,
|
|
26929
|
+
nextTierKbps,
|
|
26930
|
+
capacityMargin: this.opts.config.climbCapacityMargin,
|
|
26931
|
+
neededKbps: capacityBarKbps(next, nextTierKbps, this.opts.config.climbCapacityMargin),
|
|
26932
|
+
tryBlockedBy: extra.tryBlockedBy ?? null,
|
|
26933
|
+
tryDeliveryRatio: this.opts.config.tryClimbDeliveryRatio,
|
|
26934
|
+
tryMaxLossRatio: this.opts.config.tryClimbMaxLossRatio,
|
|
26935
|
+
retryInMs: code === "try-backoff" ? Math.max(0, this.tryCarry.notBefore - now) : null,
|
|
26936
|
+
lastRevert: this.tryCarry.lastRevert,
|
|
26937
|
+
trialForMs: code === "trial" ? extra.trialForMs ?? 0 : null,
|
|
26938
|
+
trialMs: code === "trial" ? this.opts.config.trialMs : null,
|
|
26939
|
+
healthyForMs: clockRunning ? now - this.healthySince : null,
|
|
26940
|
+
requiredHealthyMs: this.requiredHealthyMs(next),
|
|
26941
|
+
since: this.hold?.code === code ? this.hold.since : now
|
|
26942
|
+
};
|
|
26943
|
+
}
|
|
26944
|
+
/**
|
|
26945
|
+
* Judge the rung on trial. Returns true when this evaluation is consumed
|
|
26946
|
+
* (still running, or reverted); false when the trial was just kept and the
|
|
26947
|
+
* ordinary ladder takes over.
|
|
26948
|
+
*/
|
|
26949
|
+
judgeTrial(now) {
|
|
26950
|
+
const watch = this.trial;
|
|
26951
|
+
if (watch === null) return false;
|
|
26952
|
+
const verdict = watch.judge(now, {
|
|
26953
|
+
deliveryRatio: this.stalls.evidence(now).deliveryRatio,
|
|
26954
|
+
clientReporting: this.stalls.isClientReporting,
|
|
26955
|
+
stalled: this.stalls.isStalled(now),
|
|
26956
|
+
lossFailing: this.tracker.isFailing(now)
|
|
26957
|
+
});
|
|
26958
|
+
if (verdict.kind === "running") {
|
|
26959
|
+
this.healthySince = now;
|
|
26960
|
+
this.setHold("trial", now, { trialForMs: verdict.forMs });
|
|
26961
|
+
return true;
|
|
26962
|
+
}
|
|
26963
|
+
this.trial = null;
|
|
26964
|
+
if (verdict.kind === "kept") {
|
|
26965
|
+
this.tryCarry = NO_TRY_HISTORY;
|
|
26966
|
+
this.healthySince = now;
|
|
26967
|
+
return false;
|
|
26968
|
+
}
|
|
26969
|
+
const failures = this.tryCarry.failures + 1;
|
|
26970
|
+
const tried = this.tier;
|
|
26971
|
+
this.tryCarry = {
|
|
26972
|
+
failures,
|
|
26973
|
+
notBefore: now + tryBackoffMs(failures, this.opts.config.tryBackoffBaseMs, this.opts.config.tryBackoffMaxMs),
|
|
26974
|
+
lastRevert: {
|
|
26975
|
+
tier: tried,
|
|
26976
|
+
deliveryRatio: verdict.deliveryRatio,
|
|
26977
|
+
stalled: verdict.stalled,
|
|
26978
|
+
at: now
|
|
26979
|
+
},
|
|
26980
|
+
trialFrom: null
|
|
26981
|
+
};
|
|
26982
|
+
this.hold = null;
|
|
26983
|
+
this.tier = watch.from;
|
|
26984
|
+
this.mtuReduced = false;
|
|
26985
|
+
this.transcodeEngaged = false;
|
|
26986
|
+
this.opts.onSwitchTier(watch.from, this.decisionAt(now, "trial-revert"));
|
|
26987
|
+
this.commitChange(now);
|
|
26988
|
+
this.healthySince = now;
|
|
26989
|
+
return true;
|
|
26990
|
+
}
|
|
26991
|
+
/**
|
|
26992
|
+
* Which clean-path condition refuses a TRY, or null when the path is clean.
|
|
26993
|
+
* A client that sends no telemetry at all cannot measure delivery; there the
|
|
26994
|
+
* loss path is the judge, as it was before delivery existed.
|
|
26995
|
+
*/
|
|
26996
|
+
tryBlocker(now) {
|
|
26997
|
+
if (this.stalls.isTelemetryShared) return "shared-telemetry";
|
|
26998
|
+
const ratio = this.stalls.evidence(now).deliveryRatio;
|
|
26999
|
+
if (ratio === null ? this.stalls.isClientReporting : ratio < this.opts.config.tryClimbDeliveryRatio) return "delivery";
|
|
27000
|
+
if (this.tracker.lossRatio() >= this.opts.config.tryClimbMaxLossRatio) return "loss";
|
|
27001
|
+
if (this.rtt.stability(now) === "unstable") return "rtt";
|
|
27002
|
+
return null;
|
|
27003
|
+
}
|
|
26400
27004
|
canUpgrade(now) {
|
|
26401
27005
|
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
|
|
26402
27006
|
}
|
|
@@ -26417,11 +27021,12 @@ var AdaptiveController = class {
|
|
|
26417
27021
|
this.stalls.markChanged(now);
|
|
26418
27022
|
}
|
|
26419
27023
|
/** The measurement this decision rests on, snapshotted for the log line. */
|
|
26420
|
-
decisionAt(now, cause) {
|
|
27024
|
+
decisionAt(now, cause, trial = false) {
|
|
26421
27025
|
return {
|
|
26422
27026
|
cause,
|
|
26423
27027
|
lossRatio: this.tracker.lossRatio(),
|
|
26424
|
-
stall: this.stalls.evidence(now)
|
|
27028
|
+
stall: this.stalls.evidence(now),
|
|
27029
|
+
trial
|
|
26425
27030
|
};
|
|
26426
27031
|
}
|
|
26427
27032
|
handleFailing(now, cause) {
|
|
@@ -26448,21 +27053,223 @@ var AdaptiveController = class {
|
|
|
26448
27053
|
}
|
|
26449
27054
|
}
|
|
26450
27055
|
handleHealthy(now) {
|
|
26451
|
-
const
|
|
26452
|
-
if (
|
|
26453
|
-
|
|
26454
|
-
|
|
26455
|
-
|
|
27056
|
+
const next = this.nextTier();
|
|
27057
|
+
if (next === null) {
|
|
27058
|
+
this.hold = null;
|
|
27059
|
+
return;
|
|
27060
|
+
}
|
|
27061
|
+
const capacity = judgeCapacity(this.capacity, next, this.opts.expectedTierKbps?.(next) ?? null, this.opts.config.climbCapacityMargin);
|
|
27062
|
+
if (capacity.kind === "probe-short") {
|
|
27063
|
+
this.setHold("capacity", now);
|
|
27064
|
+
return;
|
|
27065
|
+
}
|
|
27066
|
+
if (capacity.kind === "none" && now < this.tryCarry.notBefore) {
|
|
27067
|
+
this.setHold("try-backoff", now);
|
|
27068
|
+
return;
|
|
27069
|
+
}
|
|
27070
|
+
if (now - this.healthySince < this.requiredHealthyMs(next)) {
|
|
27071
|
+
this.setHold("warming", now);
|
|
27072
|
+
return;
|
|
27073
|
+
}
|
|
27074
|
+
if (!this.canUpgrade(now)) {
|
|
27075
|
+
this.setHold("cooldown", now);
|
|
27076
|
+
return;
|
|
27077
|
+
}
|
|
27078
|
+
if (capacity.kind === "sufficient") {
|
|
27079
|
+
this.promote(now, next, false);
|
|
27080
|
+
return;
|
|
27081
|
+
}
|
|
27082
|
+
const blocker = this.tryBlocker(now);
|
|
27083
|
+
if (blocker !== null) {
|
|
27084
|
+
this.setHold("try-unclean", now, { tryBlockedBy: blocker });
|
|
27085
|
+
return;
|
|
27086
|
+
}
|
|
27087
|
+
this.promote(now, next, true);
|
|
27088
|
+
}
|
|
27089
|
+
promote(now, next, trial) {
|
|
27090
|
+
const from = this.tier;
|
|
27091
|
+
this.hold = null;
|
|
26456
27092
|
this.tier = next;
|
|
26457
27093
|
this.mtuReduced = false;
|
|
26458
27094
|
this.transcodeEngaged = false;
|
|
26459
27095
|
this.upgradeCount += 1;
|
|
26460
|
-
this.
|
|
27096
|
+
this.tryCarry = {
|
|
27097
|
+
...this.tryCarry,
|
|
27098
|
+
trialFrom: trial ? from : null
|
|
27099
|
+
};
|
|
27100
|
+
this.trial = trial ? new TrialWatch(from, this.opts.config) : null;
|
|
27101
|
+
this.opts.onSwitchTier(next, this.decisionAt(now, "healthy", trial));
|
|
26461
27102
|
this.commitChange(now);
|
|
26462
27103
|
this.healthySince = now;
|
|
26463
27104
|
}
|
|
26464
27105
|
};
|
|
26465
27106
|
//#endregion
|
|
27107
|
+
//#region src/stream-broker/webrtc/climb-hold.ts
|
|
27108
|
+
/** The wire shape: the hold plus the line the broker logs. */
|
|
27109
|
+
function toWireClimbHold(h) {
|
|
27110
|
+
return {
|
|
27111
|
+
...h,
|
|
27112
|
+
summary: describeClimbHold(h)
|
|
27113
|
+
};
|
|
27114
|
+
}
|
|
27115
|
+
function seconds(ms) {
|
|
27116
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
27117
|
+
}
|
|
27118
|
+
/** A rate, or "unknown" — never a fabricated 0 kbps (D393). */
|
|
27119
|
+
function kbps(v) {
|
|
27120
|
+
return v === null ? "unknown" : `${String(Math.round(v))} kbps`;
|
|
27121
|
+
}
|
|
27122
|
+
function deliveryPart(h) {
|
|
27123
|
+
if (h.deliveryRatio === null || h.deliveryWindowMs === null) return null;
|
|
27124
|
+
return `delivery ${h.deliveryRatio.toFixed(2)} over ${seconds(h.deliveryWindowMs)}`;
|
|
27125
|
+
}
|
|
27126
|
+
/** `mid needs 2160 kbps (1800 × 1.2)`, or `(client threshold)` for an unmeasured rung. */
|
|
27127
|
+
function barPart(h) {
|
|
27128
|
+
const basis = h.nextTierKbps !== null && h.nextTierKbps > 0 ? `(${String(Math.round(h.nextTierKbps))} × ${String(h.capacityMargin)})` : "(client threshold)";
|
|
27129
|
+
return `${h.nextTier} needs ${kbps(h.neededKbps)} ${basis}`;
|
|
27130
|
+
}
|
|
27131
|
+
function witnesses(h) {
|
|
27132
|
+
return `${h.capacityKbps === null ? "no REMB" : `REMB ${kbps(h.capacityKbps)}`}, ${h.probeKbps === null ? "no probe" : `probe ${kbps(h.probeKbps)}`}`;
|
|
27133
|
+
}
|
|
27134
|
+
function blockerPart(h) {
|
|
27135
|
+
switch (h.tryBlockedBy) {
|
|
27136
|
+
case "delivery": return h.deliveryRatio === null ? "delivery not measured" : `delivery ${h.deliveryRatio.toFixed(2)} < ${h.tryDeliveryRatio.toFixed(2)}`;
|
|
27137
|
+
case "loss": return `loss ≥ ${String(Math.round(h.tryMaxLossRatio * 1e3) / 10)}%`;
|
|
27138
|
+
case "rtt": return "RTT unstable";
|
|
27139
|
+
case "shared-telemetry": return "another viewer shares this camera's telemetry";
|
|
27140
|
+
case null: return "unknown";
|
|
27141
|
+
}
|
|
27142
|
+
}
|
|
27143
|
+
function revertPart(h) {
|
|
27144
|
+
const r = h.lastRevert;
|
|
27145
|
+
if (r === null) return "a try reverted";
|
|
27146
|
+
const why = r.stalled ? "stall" : r.deliveryRatio === null ? "loss" : `delivery ${r.deliveryRatio.toFixed(2)}`;
|
|
27147
|
+
return `tried ${r.tier}, reverted: ${why}`;
|
|
27148
|
+
}
|
|
27149
|
+
/**
|
|
27150
|
+
* One line, English, numbers included — the same text the broker logs and the
|
|
27151
|
+
* viewer shows under its localized hold label. Every number is one the
|
|
27152
|
+
* controller actually used; an unknown one is said to be unknown.
|
|
27153
|
+
*/
|
|
27154
|
+
function describeClimbHold(h) {
|
|
27155
|
+
const delivery = deliveryPart(h);
|
|
27156
|
+
switch (h.code) {
|
|
27157
|
+
case "stall-building": return `held: stall building (${delivery ?? "delivery unknown"})`;
|
|
27158
|
+
case "delivery": return delivery === null ? "held: delivery not yet measured over one GOP" : `held: ${delivery} < ${h.climbDeliveryRatio.toFixed(2)}`;
|
|
27159
|
+
case "no-evidence": return "held: no receiver feedback in the loss window";
|
|
27160
|
+
case "warming": return `climbing: healthy ${seconds(h.healthyForMs ?? 0)} of ${seconds(h.requiredHealthyMs ?? 0)} before ${h.nextTier}`;
|
|
27161
|
+
case "cooldown": return "held: too soon after the last change";
|
|
27162
|
+
case "capacity": return `held: probe ${kbps(h.probeKbps)}, ${h.capacityKbps === null ? "no REMB" : `REMB ${kbps(h.capacityKbps)}`} < ${barPart(h)}`;
|
|
27163
|
+
case "try-unclean": return `held: no capacity evidence for ${h.nextTier} (${witnesses(h)}); not clean enough to try: ${blockerPart(h)}`;
|
|
27164
|
+
case "try-backoff": return `held: ${revertPart(h)}; next try in ${seconds(h.retryInMs ?? 0)}`;
|
|
27165
|
+
case "trial": return `trying ${h.nextTier}: ${seconds(h.trialForMs ?? 0)} of ${seconds(h.trialMs ?? 0)} (reverts if it does not land)`;
|
|
27166
|
+
case "suspended": return "held: recorded playback on the track (ladder suspended)";
|
|
27167
|
+
}
|
|
27168
|
+
}
|
|
27169
|
+
/**
|
|
27170
|
+
* Which hold changes deserve a log line (review m3). A code logs when it
|
|
27171
|
+
* differs from the last one logged AND has not itself been logged within
|
|
27172
|
+
* `quietMs` — a link hovering at 0.90–0.95 delivery cycles delivery →
|
|
27173
|
+
* warming → try-unclean and used to write a line every 4–8 s for ever.
|
|
27174
|
+
* Progress (`warming`, `trial`) never logs; the switch line covers moves.
|
|
27175
|
+
*/
|
|
27176
|
+
var HoldLogGate = class {
|
|
27177
|
+
quietMs;
|
|
27178
|
+
last = null;
|
|
27179
|
+
loggedAt = /* @__PURE__ */ new Map();
|
|
27180
|
+
constructor(quietMs) {
|
|
27181
|
+
this.quietMs = quietMs;
|
|
27182
|
+
}
|
|
27183
|
+
shouldLog(code, now) {
|
|
27184
|
+
if (code === "warming" || code === "trial") return false;
|
|
27185
|
+
if (code === this.last) return false;
|
|
27186
|
+
const at = this.loggedAt.get(code);
|
|
27187
|
+
if (at !== void 0 && now - at < this.quietMs) return false;
|
|
27188
|
+
this.last = code;
|
|
27189
|
+
this.loggedAt.set(code, now);
|
|
27190
|
+
return true;
|
|
27191
|
+
}
|
|
27192
|
+
};
|
|
27193
|
+
//#endregion
|
|
27194
|
+
//#region src/stream-broker/webrtc/rung-bitrate.ts
|
|
27195
|
+
/**
|
|
27196
|
+
* Shortest lifetime whose average means anything: 4 000 ms ≈ one GOP on the
|
|
27197
|
+
* cameras this hub serves; less than that is one IDR divided by nothing.
|
|
27198
|
+
*/
|
|
27199
|
+
var MIN_AVERAGE_UPTIME_MS = 4e3;
|
|
27200
|
+
function expectedRungKbps(stats) {
|
|
27201
|
+
const lifetime = stats.totalBytes > 0 && stats.uptimeMs >= MIN_AVERAGE_UPTIME_MS ? Math.round(stats.totalBytes * 8 / stats.uptimeMs) : null;
|
|
27202
|
+
const windowKbps = stats.status === "streaming" && stats.bitrateKbps > 0 ? stats.bitrateKbps : null;
|
|
27203
|
+
if (windowKbps === null) return lifetime;
|
|
27204
|
+
return lifetime === null ? windowKbps : Math.max(windowKbps, lifetime);
|
|
27205
|
+
}
|
|
27206
|
+
/** The client's probe cache TTL (`DOWNLINK_TTL_MS`): the oldest a hinted probe can be. */
|
|
27207
|
+
var HINT_PROBE_MAX_AGE_MS = 3e4;
|
|
27208
|
+
function mbpsToKbps(mbps) {
|
|
27209
|
+
return mbps !== void 0 && Number.isFinite(mbps) && mbps > 0 ? mbps * 1e3 : null;
|
|
27210
|
+
}
|
|
27211
|
+
/** The probe an offer carried, dated conservatively; null when it carried none. */
|
|
27212
|
+
function probeFromHints(downlinkMbps, now) {
|
|
27213
|
+
const kbps = mbpsToKbps(downlinkMbps);
|
|
27214
|
+
return kbps === null ? null : {
|
|
27215
|
+
kbps,
|
|
27216
|
+
at: now - HINT_PROBE_MAX_AGE_MS
|
|
27217
|
+
};
|
|
27218
|
+
}
|
|
27219
|
+
/** The probe a telemetry report carried; null when it carried none. */
|
|
27220
|
+
function probeFromReport(report, now) {
|
|
27221
|
+
const kbps = mbpsToKbps(report.downlinkMbps);
|
|
27222
|
+
if (kbps === null) return null;
|
|
27223
|
+
return {
|
|
27224
|
+
kbps,
|
|
27225
|
+
at: now - Math.max(0, report.reportAgeMs) - Math.max(0, report.downlinkAgeMs)
|
|
27226
|
+
};
|
|
27227
|
+
}
|
|
27228
|
+
/** The more recent of two probes (either may be absent). */
|
|
27229
|
+
function newerProbe(a, b) {
|
|
27230
|
+
if (a === null) return b;
|
|
27231
|
+
if (b === null) return a;
|
|
27232
|
+
return b.at >= a.at ? b : a;
|
|
27233
|
+
}
|
|
27234
|
+
function isProbeFresh(probe, now) {
|
|
27235
|
+
return probe !== null && now - probe.at <= 12e4;
|
|
27236
|
+
}
|
|
27237
|
+
/**
|
|
27238
|
+
* How long the broker keeps a device's probe at all. 600 000 ms (10 min, the
|
|
27239
|
+
* try-backoff cap). A SHORT probe holds a climb until a newer probe replaces
|
|
27240
|
+
* it — and a viewer that cannot probe (no token yet, a share link, whose token
|
|
27241
|
+
* is not session-grade) never supplies one, so without an end one viewer's
|
|
27242
|
+
* 1.2 Mbps reading would hold every such viewer of the camera on `capacity`
|
|
27243
|
+
* until the broker restarted (review N1).
|
|
27244
|
+
*/
|
|
27245
|
+
var PROBE_MEMORY_TTL_MS = 6e5;
|
|
27246
|
+
/** The newest probe per device, bounded in time and forgotten with the device's last session. */
|
|
27247
|
+
var DeviceProbeMemory = class {
|
|
27248
|
+
ttlMs;
|
|
27249
|
+
probes = /* @__PURE__ */ new Map();
|
|
27250
|
+
constructor(ttlMs) {
|
|
27251
|
+
this.ttlMs = ttlMs;
|
|
27252
|
+
}
|
|
27253
|
+
/** Keep `probe` if it is newer than what is held; an absent one changes nothing. */
|
|
27254
|
+
record(deviceId, probe) {
|
|
27255
|
+
const kept = newerProbe(this.probes.get(deviceId) ?? null, probe);
|
|
27256
|
+
if (kept !== null) this.probes.set(deviceId, kept);
|
|
27257
|
+
}
|
|
27258
|
+
get(deviceId, now) {
|
|
27259
|
+
const probe = this.probes.get(deviceId);
|
|
27260
|
+
if (probe === void 0) return null;
|
|
27261
|
+
if (now - probe.at > this.ttlMs) {
|
|
27262
|
+
this.probes.delete(deviceId);
|
|
27263
|
+
return null;
|
|
27264
|
+
}
|
|
27265
|
+
return probe;
|
|
27266
|
+
}
|
|
27267
|
+
/** The device's last adaptive session ended: nobody's probe speaks for the next viewer. */
|
|
27268
|
+
forget(deviceId) {
|
|
27269
|
+
this.probes.delete(deviceId);
|
|
27270
|
+
}
|
|
27271
|
+
};
|
|
27272
|
+
//#endregion
|
|
26466
27273
|
//#region src/stream-broker/webrtc/session-debug.ts
|
|
26467
27274
|
/** `<deviceId>/<camStreamId>` → the camStreamId, or null when it is not one. */
|
|
26468
27275
|
function camStreamIdOf(brokerId) {
|
|
@@ -26501,7 +27308,8 @@ var DEAD = {
|
|
|
26501
27308
|
trackMode: null,
|
|
26502
27309
|
lastDecision: null,
|
|
26503
27310
|
tierFailures: [],
|
|
26504
|
-
upgradeCount: null
|
|
27311
|
+
upgradeCount: null,
|
|
27312
|
+
climbHold: null
|
|
26505
27313
|
};
|
|
26506
27314
|
function projectSessionDebug(input) {
|
|
26507
27315
|
const { session } = input;
|
|
@@ -26538,7 +27346,8 @@ function projectSessionDebug(input) {
|
|
|
26538
27346
|
tier,
|
|
26539
27347
|
failures
|
|
26540
27348
|
})),
|
|
26541
|
-
upgradeCount: input.ladder?.upgradeCount ?? null
|
|
27349
|
+
upgradeCount: input.ladder?.upgradeCount ?? null,
|
|
27350
|
+
climbHold: input.ladder?.climbHold ?? null
|
|
26542
27351
|
};
|
|
26543
27352
|
}
|
|
26544
27353
|
var PLAYBACK_PROFILE_FALLBACK_ORDER = [
|
|
@@ -33515,24 +34324,13 @@ function pickAdaptiveTier(input) {
|
|
|
33515
34324
|
*
|
|
33516
34325
|
* Flow: Camera → RTSP → Broker → EncodedPacket → RTP packetization → WebRTC
|
|
33517
34326
|
*/
|
|
33518
|
-
/**
|
|
33519
|
-
|
|
33520
|
-
|
|
33521
|
-
|
|
33522
|
-
|
|
33523
|
-
|
|
33524
|
-
|
|
33525
|
-
upgradeHealthyMs: 4e3,
|
|
33526
|
-
upgradeHealthyGrowthFactor: 3,
|
|
33527
|
-
maxProgressiveUpgradeHealthyMs: 3e4,
|
|
33528
|
-
minTimeBetweenChangesMs: 4e3,
|
|
33529
|
-
maxUpgradeBackoffMs: 9e5,
|
|
33530
|
-
stallKbpsFloor: 64,
|
|
33531
|
-
stallConfirmMs: 6e3,
|
|
33532
|
-
recoverKbpsFloor: 128,
|
|
33533
|
-
waitAfterChangeMs: 8e3,
|
|
33534
|
-
maxClientSampleAgeMs: 1e4
|
|
33535
|
-
};
|
|
34327
|
+
/**
|
|
34328
|
+
* A hold code that has been logged stays quiet this long before it may log
|
|
34329
|
+
* again (`HoldLogGate`). 60 000 ms: a hovering link changes code every 4–8 s.
|
|
34330
|
+
*/
|
|
34331
|
+
var HOLD_LOG_QUIET_MS = 6e4;
|
|
34332
|
+
/** A failing adaptive tick logs at most once per this long, per session. */
|
|
34333
|
+
var TICK_FAILURE_LOG_QUIET_MS = 6e4;
|
|
33536
34334
|
/**
|
|
33537
34335
|
* True when `broker` is an in-process StreamBroker exposing the Opus egress
|
|
33538
34336
|
* controls. Uses `in` narrowing + `typeof` checks (no cast) so the returned
|
|
@@ -33769,6 +34567,14 @@ var BrokerWebrtcServer = class {
|
|
|
33769
34567
|
*/
|
|
33770
34568
|
lastLadderDecision = /* @__PURE__ */ new Map();
|
|
33771
34569
|
/**
|
|
34570
|
+
* Try-climb memory per DEVICE (review I4): a new viewer session of a camera
|
|
34571
|
+
* whose next rung keeps failing inherits the backoff instead of starting at
|
|
34572
|
+
* try #1. Forgotten after the backoff cap without a refresh.
|
|
34573
|
+
*/
|
|
34574
|
+
deviceTryMemory = new DeviceTryMemory(ADAPTIVE_CONFIG.tryBackoffMaxMs);
|
|
34575
|
+
/** The newest viewer probe per device, from offers and telemetry (`probe-evidence.ts`). */
|
|
34576
|
+
deviceProbe = new DeviceProbeMemory(PROBE_MEMORY_TTL_MS);
|
|
34577
|
+
/**
|
|
33772
34578
|
* brokerId → where its media is ingested. Injected by the manager, which
|
|
33773
34579
|
* owns the ownership map; unset (or a null answer) means UNRESOLVED, and
|
|
33774
34580
|
* the overlay says so rather than naming the local node.
|
|
@@ -34766,7 +35572,8 @@ var BrokerWebrtcServer = class {
|
|
|
34766
35572
|
};
|
|
34767
35573
|
if (streamId.endsWith("/adaptive")) {
|
|
34768
35574
|
const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
|
|
34769
|
-
this.
|
|
35575
|
+
this.recordDeviceProbe(deviceId, probeFromHints(hints.downlinkMbps, Date.now()));
|
|
35576
|
+
this.startAdaptive(entry, deviceId, initialTier, sessionId, intent);
|
|
34770
35577
|
}
|
|
34771
35578
|
return {
|
|
34772
35579
|
sessionId,
|
|
@@ -34884,11 +35691,13 @@ var BrokerWebrtcServer = class {
|
|
|
34884
35691
|
const ladder = controller !== void 0 ? {
|
|
34885
35692
|
lastDecision: decision,
|
|
34886
35693
|
failures: [...controller.failureCounts],
|
|
34887
|
-
upgradeCount: controller.successfulUpgradeCount
|
|
35694
|
+
upgradeCount: controller.successfulUpgradeCount,
|
|
35695
|
+
climbHold: controller.climbHold === null ? null : toWireClimbHold(controller.climbHold)
|
|
34888
35696
|
} : decision !== null ? {
|
|
34889
35697
|
lastDecision: decision,
|
|
34890
35698
|
failures: [],
|
|
34891
|
-
upgradeCount: 0
|
|
35699
|
+
upgradeCount: 0,
|
|
35700
|
+
climbHold: null
|
|
34892
35701
|
} : null;
|
|
34893
35702
|
let brokerStats = null;
|
|
34894
35703
|
try {
|
|
@@ -34931,7 +35740,7 @@ var BrokerWebrtcServer = class {
|
|
|
34931
35740
|
/** Snapshot the ladder's move so the overlay can name it later. */
|
|
34932
35741
|
recordLadderDecision(sessionId, decision, tier, forcedTranscodeDown) {
|
|
34933
35742
|
this.lastLadderDecision.set(sessionId, {
|
|
34934
|
-
|
|
35743
|
+
...wireLadderCause(decision, ADAPTIVE_CONFIG.stallConfirmMs),
|
|
34935
35744
|
tier,
|
|
34936
35745
|
forcedTranscodeDown,
|
|
34937
35746
|
lossRatio: decision.lossRatio,
|
|
@@ -34942,6 +35751,39 @@ var BrokerWebrtcServer = class {
|
|
|
34942
35751
|
});
|
|
34943
35752
|
}
|
|
34944
35753
|
/**
|
|
35754
|
+
* The IDR interval the broker feeding a session measured, or null when it
|
|
35755
|
+
* has measured none (a fresh broker reports 0). Sizes the delivery window.
|
|
35756
|
+
*/
|
|
35757
|
+
brokerIdrIntervalMs(brokerId) {
|
|
35758
|
+
try {
|
|
35759
|
+
const idr = this.brokers.get(brokerId)?.getStats().idrIntervalMs ?? null;
|
|
35760
|
+
return idr !== null && idr > 0 ? idr : null;
|
|
35761
|
+
} catch {
|
|
35762
|
+
return null;
|
|
35763
|
+
}
|
|
35764
|
+
}
|
|
35765
|
+
/**
|
|
35766
|
+
* The expected bitrate of `tier` for this device, from the broker serving
|
|
35767
|
+
* that rung — null when no such broker is registered here or it has
|
|
35768
|
+
* measured nothing. Unknown never vetoes a climb (D315/D393).
|
|
35769
|
+
*/
|
|
35770
|
+
expectedTierKbps(deviceId, tier) {
|
|
35771
|
+
const prefix = `${String(deviceId)}/`;
|
|
35772
|
+
const candidates = [];
|
|
35773
|
+
for (const [bid, broker] of this.brokers) if (bid.startsWith(prefix)) candidates.push({
|
|
35774
|
+
brokerId: bid,
|
|
35775
|
+
tier: this.profileTierResolver?.(bid) ?? null,
|
|
35776
|
+
broker
|
|
35777
|
+
});
|
|
35778
|
+
const match = preferLiveAdaptiveCandidates(candidates).find((c) => c.tier === tier);
|
|
35779
|
+
if (match === void 0) return null;
|
|
35780
|
+
try {
|
|
35781
|
+
return expectedRungKbps(match.broker.getStats());
|
|
35782
|
+
} catch {
|
|
35783
|
+
return null;
|
|
35784
|
+
}
|
|
35785
|
+
}
|
|
35786
|
+
/**
|
|
34945
35787
|
* Wire an {@link AdaptiveController} to a freshly-created adaptive session.
|
|
34946
35788
|
* The controller is fed loss from two sources — RTCP Receiver Reports
|
|
34947
35789
|
* (`onReceiverReport`) and, when injected, the device's client-reported
|
|
@@ -34958,12 +35800,20 @@ var BrokerWebrtcServer = class {
|
|
|
34958
35800
|
* whichever produces the higher loss ratio across its window. The async
|
|
34959
35801
|
* poll is guarded against overlapping ticks via `tickInFlight`.
|
|
34960
35802
|
*/
|
|
34961
|
-
startAdaptive(entry, deviceId, initialTier, sessionId,
|
|
35803
|
+
startAdaptive(entry, deviceId, initialTier, sessionId, intent) {
|
|
35804
|
+
const startedAt = Date.now();
|
|
35805
|
+
/** Rung bitrates, looked up at most once per tier per tick (review m12). */
|
|
35806
|
+
const rungKbps = /* @__PURE__ */ new Map();
|
|
34962
35807
|
const controller = new AdaptiveController({
|
|
34963
35808
|
initialTier,
|
|
34964
|
-
priorFailures,
|
|
34965
|
-
priorUpgradeCount,
|
|
35809
|
+
priorFailures: intent?.failures,
|
|
35810
|
+
priorUpgradeCount: intent?.upgradeCount,
|
|
35811
|
+
tryClimb: intent?.tryClimb ?? this.deviceTryMemory.get(deviceId, startedAt) ?? void 0,
|
|
34966
35812
|
config: ADAPTIVE_CONFIG,
|
|
35813
|
+
expectedTierKbps: (tier) => {
|
|
35814
|
+
if (!rungKbps.has(tier)) rungKbps.set(tier, this.expectedTierKbps(deviceId, tier));
|
|
35815
|
+
return rungKbps.get(tier) ?? null;
|
|
35816
|
+
},
|
|
34967
35817
|
onSwitchTier: (tier, decision) => {
|
|
34968
35818
|
this.recordLadderDecision(sessionId, decision, tier, false);
|
|
34969
35819
|
this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
|
|
@@ -34980,6 +35830,9 @@ var BrokerWebrtcServer = class {
|
|
|
34980
35830
|
reason: decision.cause,
|
|
34981
35831
|
lossRatio: Number(decision.lossRatio.toFixed(4)),
|
|
34982
35832
|
clientKbps: decision.stall.clientKbps,
|
|
35833
|
+
sentKbps: decision.stall.sentKbps,
|
|
35834
|
+
deliveryRatio: decision.stall.deliveryRatio,
|
|
35835
|
+
deliveryWindowMs: decision.stall.deliveryWindowMs,
|
|
34983
35836
|
stalledForMs: decision.stall.stalledForMs,
|
|
34984
35837
|
serverSending: decision.stall.serverSending
|
|
34985
35838
|
}
|
|
@@ -34999,14 +35852,39 @@ var BrokerWebrtcServer = class {
|
|
|
34999
35852
|
* sending?" half of the stall verdict. */
|
|
35000
35853
|
let lastRtpSent = entry.session.rtpPacketsSentTotal;
|
|
35001
35854
|
const CLIENT_LOSS_SPAN = 1e3;
|
|
35855
|
+
const meter = new DeliveryMeter(ADAPTIVE_DELIVERY_CONFIG, deliveryMeterStart(startedAt, intent !== void 0, ADAPTIVE_CONFIG.waitAfterChangeMs));
|
|
35856
|
+
const holdLog = new HoldLogGate(HOLD_LOG_QUIET_MS);
|
|
35857
|
+
let lastTickFailureLogAt = Number.NEGATIVE_INFINITY;
|
|
35858
|
+
const logHoldChange = (now) => {
|
|
35859
|
+
const hold = controller.climbHold;
|
|
35860
|
+
if (hold === null || !holdLog.shouldLog(hold.code, now)) return;
|
|
35861
|
+
this.logger.info("adaptive: climb held", {
|
|
35862
|
+
tags: { deviceId },
|
|
35863
|
+
meta: {
|
|
35864
|
+
sessionId,
|
|
35865
|
+
deviceId,
|
|
35866
|
+
tier: controller.currentTier,
|
|
35867
|
+
nextTier: hold.nextTier,
|
|
35868
|
+
code: hold.code,
|
|
35869
|
+
summary: describeClimbHold(hold),
|
|
35870
|
+
deliveryRatio: hold.deliveryRatio,
|
|
35871
|
+
deliveryWindowMs: hold.deliveryWindowMs,
|
|
35872
|
+
capacityKbps: hold.capacityKbps,
|
|
35873
|
+
probeKbps: hold.probeKbps,
|
|
35874
|
+
nextTierKbps: hold.nextTierKbps
|
|
35875
|
+
}
|
|
35876
|
+
});
|
|
35877
|
+
};
|
|
35002
35878
|
entry.adaptiveTimer = setInterval(() => {
|
|
35003
35879
|
if (tickInFlight) return;
|
|
35004
35880
|
tickInFlight = true;
|
|
35005
35881
|
(async () => {
|
|
35006
35882
|
try {
|
|
35883
|
+
rungKbps.clear();
|
|
35007
35884
|
if (entry.timeline?.currentMode === "recorded") {
|
|
35008
35885
|
controller.suspend(Date.now());
|
|
35009
35886
|
lastRtpSent = entry.session.rtpPacketsSentTotal;
|
|
35887
|
+
logHoldChange(Date.now());
|
|
35010
35888
|
return;
|
|
35011
35889
|
}
|
|
35012
35890
|
const client = this.clientNetworkStats ? await this.clientNetworkStats(deviceId) : null;
|
|
@@ -35014,11 +35892,15 @@ var BrokerWebrtcServer = class {
|
|
|
35014
35892
|
const rtpSent = entry.session.rtpPacketsSentTotal;
|
|
35015
35893
|
const serverSending = rtpSent > lastRtpSent;
|
|
35016
35894
|
lastRtpSent = rtpSent;
|
|
35017
|
-
|
|
35018
|
-
|
|
35019
|
-
|
|
35020
|
-
|
|
35021
|
-
|
|
35895
|
+
this.feedAdaptive({
|
|
35896
|
+
entry,
|
|
35897
|
+
deviceId,
|
|
35898
|
+
controller,
|
|
35899
|
+
meter,
|
|
35900
|
+
client,
|
|
35901
|
+
serverSending,
|
|
35902
|
+
now
|
|
35903
|
+
});
|
|
35022
35904
|
const pct = client?.packetLossPercent ?? null;
|
|
35023
35905
|
if (pct !== null && pct !== void 0) {
|
|
35024
35906
|
clientHighestSequence += CLIENT_LOSS_SPAN;
|
|
@@ -35029,6 +35911,21 @@ var BrokerWebrtcServer = class {
|
|
|
35029
35911
|
}, now);
|
|
35030
35912
|
}
|
|
35031
35913
|
controller.tick(now);
|
|
35914
|
+
this.deviceTryMemory.set(deviceId, controller.tryClimbCarry, now);
|
|
35915
|
+
logHoldChange(now);
|
|
35916
|
+
} catch (err) {
|
|
35917
|
+
const now = Date.now();
|
|
35918
|
+
if (now - lastTickFailureLogAt >= TICK_FAILURE_LOG_QUIET_MS) {
|
|
35919
|
+
lastTickFailureLogAt = now;
|
|
35920
|
+
this.logger.warn("adaptive: tick failed", {
|
|
35921
|
+
tags: { deviceId },
|
|
35922
|
+
meta: {
|
|
35923
|
+
sessionId,
|
|
35924
|
+
deviceId,
|
|
35925
|
+
error: require_dist.errMsg(err)
|
|
35926
|
+
}
|
|
35927
|
+
});
|
|
35928
|
+
}
|
|
35032
35929
|
} finally {
|
|
35033
35930
|
tickInFlight = false;
|
|
35034
35931
|
}
|
|
@@ -35036,6 +35933,55 @@ var BrokerWebrtcServer = class {
|
|
|
35036
35933
|
}, ADAPTIVE_TICK_MS);
|
|
35037
35934
|
}
|
|
35038
35935
|
/**
|
|
35936
|
+
* One evaluation's transport, capacity and RTT evidence into the controller.
|
|
35937
|
+
* The device-scoped client record is used as this session's own only when
|
|
35938
|
+
* this is the device's ONLY adaptive session here (review I2).
|
|
35939
|
+
*/
|
|
35940
|
+
feedAdaptive(feed) {
|
|
35941
|
+
const { entry, deviceId, controller, meter, client, now } = feed;
|
|
35942
|
+
const sessionsOnDevice = this.adaptiveSessionCount(deviceId);
|
|
35943
|
+
const shared = sessionsOnDevice > 1;
|
|
35944
|
+
let rttMs = null;
|
|
35945
|
+
if (client) {
|
|
35946
|
+
const reportAgeMs = Math.max(0, now - client.lastUpdated);
|
|
35947
|
+
meter.observe(client.estimatedBandwidthKbps, client.lastUpdated);
|
|
35948
|
+
if (!shared) {
|
|
35949
|
+
this.recordDeviceProbe(deviceId, probeFromReport({
|
|
35950
|
+
downlinkMbps: client.downlinkMbps,
|
|
35951
|
+
downlinkAgeMs: client.downlinkAgeMs ?? 0,
|
|
35952
|
+
reportAgeMs
|
|
35953
|
+
}, now));
|
|
35954
|
+
if (reportAgeMs <= ADAPTIVE_DELIVERY_CONFIG.maxClientSampleAgeMs) rttMs = client.rttMs ?? null;
|
|
35955
|
+
}
|
|
35956
|
+
}
|
|
35957
|
+
const history = entry.session.sentVideoHistory;
|
|
35958
|
+
controller.onThroughput(buildStallSample({
|
|
35959
|
+
delivery: meter.evaluate(history, deliveryWindowMs(this.brokerIdrIntervalMs(entry.brokerId), ADAPTIVE_DELIVERY_CONFIG), now),
|
|
35960
|
+
recent: meter.evaluateNewest(history, now),
|
|
35961
|
+
clientReporting: meter.isReporting(now),
|
|
35962
|
+
serverSending: feed.serverSending,
|
|
35963
|
+
adaptiveSessionsOnDevice: sessionsOnDevice
|
|
35964
|
+
}), now);
|
|
35965
|
+
const probe = this.deviceProbe.get(deviceId, now);
|
|
35966
|
+
controller.onCapacity({
|
|
35967
|
+
rembKbps: entry.session.receiverEstimateKbps(now),
|
|
35968
|
+
probeKbps: probe?.kbps ?? null,
|
|
35969
|
+
probeFresh: isProbeFresh(probe, now)
|
|
35970
|
+
});
|
|
35971
|
+
controller.onRtt(rttMs, now);
|
|
35972
|
+
}
|
|
35973
|
+
/** Adaptive sessions of `deviceId` currently on this broker server. */
|
|
35974
|
+
adaptiveSessionCount(deviceId) {
|
|
35975
|
+
const prefix = `${String(deviceId)}/`;
|
|
35976
|
+
let count = 0;
|
|
35977
|
+
for (const s of this.sessions.values()) if (s.adaptiveController !== void 0 && s.brokerId.startsWith(prefix)) count += 1;
|
|
35978
|
+
return count;
|
|
35979
|
+
}
|
|
35980
|
+
/** Keep the newest probe per device; an absent one changes nothing. */
|
|
35981
|
+
recordDeviceProbe(deviceId, probe) {
|
|
35982
|
+
this.deviceProbe.record(deviceId, probe);
|
|
35983
|
+
}
|
|
35984
|
+
/**
|
|
35039
35985
|
* Schedule an adaptive tier switch via a close-and-recreate re-offer.
|
|
35040
35986
|
*
|
|
35041
35987
|
* Records the controller's desired tier (+ forced transcode-down) in
|
|
@@ -35052,11 +35998,18 @@ var BrokerWebrtcServer = class {
|
|
|
35052
35998
|
const prev = this.adaptiveIntent.get(sessionId);
|
|
35053
35999
|
const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
|
|
35054
36000
|
const upgradeCount = entry.adaptiveController?.successfulUpgradeCount ?? prev?.upgradeCount ?? 0;
|
|
36001
|
+
const tryClimb = entry.adaptiveController?.tryClimbCarry ?? prev?.tryClimb;
|
|
35055
36002
|
this.adaptiveIntent.set(sessionId, {
|
|
35056
36003
|
prefersTier: tier,
|
|
35057
36004
|
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
|
|
35058
36005
|
failures,
|
|
35059
|
-
upgradeCount
|
|
36006
|
+
upgradeCount,
|
|
36007
|
+
tryClimb: tryClimb ?? {
|
|
36008
|
+
failures: 0,
|
|
36009
|
+
notBefore: 0,
|
|
36010
|
+
lastRevert: null,
|
|
36011
|
+
trialFrom: null
|
|
36012
|
+
}
|
|
35060
36013
|
});
|
|
35061
36014
|
const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
|
|
35062
36015
|
entry.session.pendingRenegotiation = {
|
|
@@ -35074,8 +36027,12 @@ var BrokerWebrtcServer = class {
|
|
|
35074
36027
|
failures: Object.fromEntries(failures),
|
|
35075
36028
|
upgradeCount,
|
|
35076
36029
|
reason: decision?.cause ?? "unknown",
|
|
36030
|
+
trial: decision?.trial ?? false,
|
|
35077
36031
|
lossRatio: decision ? Number(decision.lossRatio.toFixed(4)) : null,
|
|
35078
36032
|
clientKbps: decision?.stall.clientKbps ?? null,
|
|
36033
|
+
sentKbps: decision?.stall.sentKbps ?? null,
|
|
36034
|
+
deliveryRatio: decision?.stall.deliveryRatio ?? null,
|
|
36035
|
+
deliveryWindowMs: decision?.stall.deliveryWindowMs ?? null,
|
|
35079
36036
|
stalledForMs: decision?.stall.stalledForMs ?? null,
|
|
35080
36037
|
serverSending: decision?.stall.serverSending ?? null
|
|
35081
36038
|
}
|
|
@@ -35092,6 +36049,10 @@ var BrokerWebrtcServer = class {
|
|
|
35092
36049
|
}
|
|
35093
36050
|
if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
|
|
35094
36051
|
this.adaptiveIntent.delete(sessionId);
|
|
36052
|
+
if (entry.adaptiveController !== void 0) {
|
|
36053
|
+
const deviceId = deviceIdFromBrokerId(entry.brokerId);
|
|
36054
|
+
if (this.adaptiveSessionCount(deviceId) === 0) this.deviceProbe.forget(deviceId);
|
|
36055
|
+
}
|
|
35095
36056
|
this.pruneLadderDecisions();
|
|
35096
36057
|
if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
|
|
35097
36058
|
if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
|
|
@@ -36252,7 +37213,10 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
36252
37213
|
return {
|
|
36253
37214
|
packetLossPercent: client.packetLossPercent,
|
|
36254
37215
|
estimatedBandwidthKbps: client.estimatedBandwidthKbps,
|
|
36255
|
-
lastUpdated: client.lastUpdated
|
|
37216
|
+
lastUpdated: client.lastUpdated,
|
|
37217
|
+
rttMs: client.rttMs,
|
|
37218
|
+
...client.downlinkMbps !== void 0 ? { downlinkMbps: client.downlinkMbps } : {},
|
|
37219
|
+
...client.downlinkAgeMs !== void 0 ? { downlinkAgeMs: client.downlinkAgeMs } : {}
|
|
36256
37220
|
};
|
|
36257
37221
|
} catch {
|
|
36258
37222
|
return null;
|