@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.
Files changed (33) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-B64MIW2h.mjs → default-detection-model-DcTy-nJl.mjs} +1 -1
  4. package/dist/{default-detection-model-CN_Iu8NF.js → default-detection-model-jjdmKKr3.js} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-C4JqXCw-.mjs → dist-Bn6BQW8q.mjs} +487 -60
  8. package/dist/{dist-BIlnUdBn.js → dist-D7FonEUB.js} +487 -60
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-C-WfKJ6b.mjs → node-BN0Na3HN.mjs} +1 -1
  12. package/dist/{node-CVlJhqng.js → node-D3C1krEi.js} +1 -1
  13. package/dist/pipeline-runner/index.js +3 -3
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-CJKKFNar.mjs → process-memory-CeZ8ms80.mjs} +1 -1
  16. package/dist/{process-memory-ZUucMC8Z.js → process-memory-DI4jrCtX.js} +1 -1
  17. package/dist/recorder/index.js +8 -2
  18. package/dist/recorder/index.mjs +8 -2
  19. package/dist/{segment-demux-js-4OAHyQ3t.mjs → segment-demux-js-CNPzlcyO.mjs} +1 -1
  20. package/dist/{segment-demux-js-D0mqN4RC.js → segment-demux-js-COooJ-t0.js} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. 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
  23. 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
  24. 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
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-DPdwl8-T.mjs → hostInit-CbSGtrCj.mjs} +2 -2
  28. package/dist/stream-broker/index.js +1056 -92
  29. package/dist/stream-broker/index.mjs +1056 -92
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. 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
  33. 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-BIlnUdBn.js");
7
- const require_node = require("../node-CVlJhqng.js");
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-D0mqN4RC.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
- clientKbps: null,
26190
- stalledForMs: 0,
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 rate at or above `recoverKbpsFloor` CLEARS the stall, a rate
26206
- * below `stallKbpsFloor` starts or extends it, and anything between the two
26207
- * (or unknown, or with the server idle) HOLDS whatever the window already
26208
- * believed. Holding is what stops a borderline link from oscillating.
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 kbps = sample.clientKbps;
26221
- const age = sample.clientSampleAgeMs;
26222
- if (!(kbps !== null && Number.isFinite(kbps) && age !== null && age <= this.config.maxClientSampleAgeMs) || kbps === null) {
26223
- this.lastEvidence = {
26224
- clientKbps: null,
26225
- stalledForMs: this.stalledForMs(now),
26226
- serverSending: sample.serverSending
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
- * False when the last fresh measurement was below `recoverKbpsFloor` —
26250
- * either a stall building, or a link crawling along in the dead band.
26251
- * Promoting there spends bandwidth the session has just been shown not to
26252
- * have, and the loss path cannot object: a stream delivering almost nothing
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 does not block. A deployment with no client telemetry at all must
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
- isClimbSafe(now) {
26261
- if (now < this.blackoutUntil) return true;
26262
- if (this.stallStartedAt !== null) return false;
26263
- const last = this.lastEvidence.clientKbps;
26264
- if (last === null) return true;
26265
- return last >= this.config.recoverKbpsFloor;
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
- if (!this.stalls.isClimbSafe(now)) {
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 idx = TIER_ORDER.indexOf(this.tier);
26452
- if (idx <= 0) return;
26453
- const next = TIER_ORDER[idx - 1];
26454
- if (now - this.healthySince < this.requiredHealthyMs(next)) return;
26455
- if (!this.canUpgrade(now)) return;
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.opts.onSwitchTier(next, this.decisionAt(now, "healthy"));
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
- /** How often the adaptive controller re-evaluates the ladder (ms). */
33519
- var ADAPTIVE_TICK_MS = 2e3;
33520
- /** Ladder tuning shared by every adaptive session. */
33521
- var ADAPTIVE_CONFIG = {
33522
- analyzeWindowMs: 15e3,
33523
- minFailPercent: .05,
33524
- waitAfterResetMs: 8e3,
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.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures, intent?.upgradeCount);
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
- cause: decision.cause,
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, priorFailures, priorUpgradeCount) {
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
- controller.onThroughput({
35018
- clientKbps: client ? client.estimatedBandwidthKbps : null,
35019
- clientSampleAgeMs: client ? Math.max(0, now - client.lastUpdated) : null,
35020
- serverSending
35021
- }, now);
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;