@camstack/addon-pipeline 1.2.290 → 1.2.292

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-BfrONGaR.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-0k0JYZnk.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
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $t as errMsg, An as literal, Dn as boolean, En as array, Fn as string, H as addonWidgetsSourceCapability, In as union, Jt as streamBrokerCapability, K as batteryCapability, Ln as EventCategory, M as RATE_CONTROL_TIGHT, Mn as object, On as discriminatedUnion, Pn as record, Pt as rankStreams, Q as declareLogChannel, Qt as webrtcSessionCapability, R as RingBuffer, Sn as parseProfileBrokerId, Tn as _enum, Vt as resolveEgressDecodeHwAccel, X as createHwAccelCache, Y as clipExportFailureOf, Z as createLogChannelsProvider, _n as isEvent, _t as loadContributionCapability, a as CLIP_BROKER_PACED_RATES, an as BOOT_RECOVERY_BACKOFF_MS, at as egressTransportFromRequest, bn as nodePin, bt as maskUrlCredentials, cn as CamProfileSchema, en as AUDIO_PRESETS, et as deriveBatteryPresence, fn as createEvent, hn as expandAudioChunkToF32le, i as BatteryStatusSchema, in as isSoftwareDecode, it as egressTranscodeSharingKey, j as RATE_CONTROL_RELAXED, jn as number, ln as DeviceFeature, nn as buildFfmpegArgs$1, o as CLIP_STILL_REASON_CODE_HEADER, on as BaseAddon, q as cameraStreamsCapability, rn as invocationFromEncodeProfile, sn as CAM_PROFILE_ORDER, un as DeviceType, vn as makeProfileBrokerId, vt as logChannelsCapability, w as G711_SCALE_CORRECTION_DB, x as EncodeProfileSchema, yn as makeSourceBrokerId } from "../dist-C4JqXCw-.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-C-WfKJ6b.mjs";
2
+ import { $t as errMsg, An as literal, Dn as boolean, En as array, Fn as string, H as addonWidgetsSourceCapability, In as union, Jt as streamBrokerCapability, K as batteryCapability, Ln as EventCategory, M as RATE_CONTROL_TIGHT, Mn as object, On as discriminatedUnion, Pn as record, Pt as rankStreams, Q as declareLogChannel, Qt as webrtcSessionCapability, R as RingBuffer, Sn as parseProfileBrokerId, Tn as _enum, Vt as resolveEgressDecodeHwAccel, X as createHwAccelCache, Y as clipExportFailureOf, Z as createLogChannelsProvider, _n as isEvent, _t as loadContributionCapability, a as CLIP_BROKER_PACED_RATES, an as BOOT_RECOVERY_BACKOFF_MS, at as egressTransportFromRequest, bn as nodePin, bt as maskUrlCredentials, cn as CamProfileSchema, en as AUDIO_PRESETS, et as deriveBatteryPresence, fn as createEvent, hn as expandAudioChunkToF32le, i as BatteryStatusSchema, in as isSoftwareDecode, it as egressTranscodeSharingKey, j as RATE_CONTROL_RELAXED, jn as number, ln as DeviceFeature, nn as buildFfmpegArgs$1, o as CLIP_STILL_REASON_CODE_HEADER, on as BaseAddon, q as cameraStreamsCapability, rn as invocationFromEncodeProfile, sn as CAM_PROFILE_ORDER, un as DeviceType, vn as makeProfileBrokerId, vt as logChannelsCapability, w as G711_SCALE_CORRECTION_DB, x as EncodeProfileSchema, yn as makeSourceBrokerId } from "../dist-Bn6BQW8q.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-BN0Na3HN.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { a as avccSampleToAnnexB, i as require_mp4box_all, n as demuxSegmentPreferJs, o as buildKeyframeAnnexB, r as runFfmpegOnce, s as parseNalLengthSize, t as demuxSegmentFullJs } from "../segment-demux-js-4OAHyQ3t.mjs";
7
+ import { a as avccSampleToAnnexB, i as require_mp4box_all, n as demuxSegmentPreferJs, o as buildKeyframeAnnexB, r as runFfmpegOnce, s as parseNalLengthSize, t as demuxSegmentFullJs } from "../segment-demux-js-CNPzlcyO.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -23177,6 +23177,246 @@ var H264Repacketizer = class {
23177
23177
  }
23178
23178
  };
23179
23179
  //#endregion
23180
+ //#region src/stream-broker/webrtc/delivery-meter.ts
23181
+ /** The window the ladder judges delivery over: at least one GOP. */
23182
+ function deliveryWindowMs(idrIntervalMs, config) {
23183
+ const gop = idrIntervalMs !== null && Number.isFinite(idrIntervalMs) && idrIntervalMs > 0 ? idrIntervalMs : 0;
23184
+ return Math.min(config.maxWindowMs, Math.max(config.minWindowMs, gop));
23185
+ }
23186
+ /**
23187
+ * When a session's meter may start counting client reports. A session created
23188
+ * by a tier change starts after the change blackout (review I1): until the
23189
+ * client swaps its player onto the new PC (after the new stream's first
23190
+ * decode, 2–5 s), its reporter still reads the OLD PC — 0 kbps — and the
23191
+ * first report after the swap is 0 too; both fall inside the new session's
23192
+ * sends and read as a failed rung.
23193
+ */
23194
+ function deliveryMeterStart(now, afterTierChange, waitAfterChangeMs) {
23195
+ return afterTierChange ? now + waitAfterChangeMs : now;
23196
+ }
23197
+ /**
23198
+ * Bucket width. 100 ms, and edges round to the NEAREST boundary, so a frame is
23199
+ * put on the wrong side of a window edge only when it was sent within 50 ms of
23200
+ * it — about one frame at 25 fps. Rounding edges inward instead dropped a whole
23201
+ * IDR that sat 160 ms inside a window and read a whole rung as 1.5× delivered.
23202
+ * 32 s of history is 320 slots.
23203
+ */
23204
+ var SEND_BUCKET_MS = 100;
23205
+ /**
23206
+ * Bytes this session sent, bucketed by time. A fixed ring: `record` is on the
23207
+ * RTP send path, so it allocates nothing.
23208
+ */
23209
+ var SendHistory = class {
23210
+ bytes;
23211
+ /** The bucket index each slot currently holds; -1 = empty. */
23212
+ ids;
23213
+ constructor(spanMs) {
23214
+ const slots = Math.max(1, Math.ceil(spanMs / SEND_BUCKET_MS));
23215
+ this.bytes = new Float64Array(slots);
23216
+ this.ids = new Float64Array(slots).fill(-1);
23217
+ }
23218
+ record(byteCount, now) {
23219
+ const id = Math.floor(now / SEND_BUCKET_MS);
23220
+ const slot = id % this.ids.length;
23221
+ if (this.ids[slot] !== id) {
23222
+ this.ids[slot] = id;
23223
+ this.bytes[slot] = 0;
23224
+ }
23225
+ this.bytes[slot] = (this.bytes[slot] ?? 0) + byteCount;
23226
+ }
23227
+ bitsBetween(fromMs, toMs) {
23228
+ const first = Math.round(fromMs / SEND_BUCKET_MS);
23229
+ const last = Math.round(toMs / SEND_BUCKET_MS) - 1;
23230
+ let bytes = 0;
23231
+ for (let id = first; id <= last; id += 1) {
23232
+ const slot = id % this.ids.length;
23233
+ if (this.ids[slot] === id) bytes += this.bytes[slot] ?? 0;
23234
+ }
23235
+ return bytes * 8;
23236
+ }
23237
+ };
23238
+ /**
23239
+ * The client's recent reports, and the ratio they imply against a
23240
+ * {@link SendHistoryReader}. One meter per adaptive session: `notBefore` is
23241
+ * the session's start, because the reports are DEVICE-scoped and a report
23242
+ * about time before this session describes the previous one (a re-offer).
23243
+ */
23244
+ var DeliveryMeter = class {
23245
+ config;
23246
+ notBefore;
23247
+ reports = [];
23248
+ constructor(config, notBefore) {
23249
+ this.config = config;
23250
+ this.notBefore = notBefore;
23251
+ }
23252
+ /** Fold one client report. The same report read on several ticks counts once. */
23253
+ observe(kbps, endAt) {
23254
+ if (!Number.isFinite(kbps) || kbps < 0 || !Number.isFinite(endAt)) return;
23255
+ if (endAt - this.config.clientSampleSpanMs < this.notBefore) return;
23256
+ if (this.reports.some((r) => r.endAt === endAt)) return;
23257
+ const horizon = endAt - this.config.historySpanMs;
23258
+ this.reports = [...this.reports.filter((r) => r.endAt > horizon), {
23259
+ kbps,
23260
+ endAt
23261
+ }].sort((a, b) => a.endAt - b.endAt);
23262
+ }
23263
+ /**
23264
+ * Is the client reporting at all? A fresh report that does not yet cover a
23265
+ * whole window is "not measured YET" — different from a client that sends
23266
+ * no telemetry, and it must not be climbed on (a 10 %-delivery link would
23267
+ * otherwise promote in the seconds before its first window closes).
23268
+ */
23269
+ isReporting(now) {
23270
+ const newest = this.reports.at(-1);
23271
+ return newest !== void 0 && now - newest.endAt <= this.config.maxClientSampleAgeMs;
23272
+ }
23273
+ /**
23274
+ * The newest report alone against the same 2 s of sends — the short window
23275
+ * a total freeze is judged on. Null when there is none, or it is stale.
23276
+ */
23277
+ evaluateNewest(history, now) {
23278
+ const newest = this.reports.at(-1);
23279
+ if (newest === void 0 || now - newest.endAt > this.config.maxClientSampleAgeMs) return null;
23280
+ const span = this.config.clientSampleSpanMs;
23281
+ return {
23282
+ receivedKbps: newest.kbps,
23283
+ sentKbps: history.bitsBetween(newest.endAt - span, newest.endAt) / span,
23284
+ windowMs: span
23285
+ };
23286
+ }
23287
+ /**
23288
+ * Received vs sent over the newest reports that together cover `windowMs`,
23289
+ * or `null` when they do not (yet) — or the newest one is stale.
23290
+ */
23291
+ evaluate(history, windowMs, now) {
23292
+ const newest = this.reports.at(-1);
23293
+ if (newest === void 0 || now - newest.endAt > this.config.maxClientSampleAgeMs) return null;
23294
+ const span = this.config.clientSampleSpanMs;
23295
+ let covered = 0;
23296
+ let receivedBits = 0;
23297
+ let sentBits = 0;
23298
+ for (let i = this.reports.length - 1; i >= 0 && covered < windowMs; i -= 1) {
23299
+ const r = this.reports[i];
23300
+ if (r === void 0 || now - (r.endAt - span) > this.config.historySpanMs) break;
23301
+ covered += span;
23302
+ receivedBits += r.kbps * span;
23303
+ sentBits += history.bitsBetween(r.endAt - span, r.endAt);
23304
+ }
23305
+ if (covered < windowMs) return null;
23306
+ return {
23307
+ receivedKbps: receivedBits / covered,
23308
+ sentKbps: sentBits / covered,
23309
+ windowMs: covered
23310
+ };
23311
+ }
23312
+ };
23313
+ //#endregion
23314
+ //#region src/stream-broker/webrtc/adaptive-config.ts
23315
+ /** How often the adaptive controller re-evaluates the ladder (ms). */
23316
+ var ADAPTIVE_TICK_MS = 2e3;
23317
+ /** Ladder tuning shared by every adaptive session. */
23318
+ var ADAPTIVE_CONFIG = {
23319
+ analyzeWindowMs: 15e3,
23320
+ minFailPercent: .05,
23321
+ waitAfterResetMs: 8e3,
23322
+ upgradeHealthyMs: 4e3,
23323
+ upgradeHealthyGrowthFactor: 3,
23324
+ maxProgressiveUpgradeHealthyMs: 3e4,
23325
+ minTimeBetweenChangesMs: 4e3,
23326
+ maxUpgradeBackoffMs: 9e5,
23327
+ stallDeliveryRatio: .25,
23328
+ recoverDeliveryRatio: .5,
23329
+ freezeDeliveryRatio: .1,
23330
+ freezeMinSentKbps: 16,
23331
+ climbDeliveryRatio: .9,
23332
+ stallConfirmMs: 6e3,
23333
+ waitAfterChangeMs: 8e3,
23334
+ climbCapacityMargin: 1.2,
23335
+ tryClimbDeliveryRatio: .95,
23336
+ tryClimbMaxLossRatio: .01,
23337
+ tryBackoffBaseMs: 3e4,
23338
+ tryBackoffMaxMs: 6e5,
23339
+ trialMs: 2e4,
23340
+ trialMaxMs: 4e4,
23341
+ trialRevertRatio: .9,
23342
+ trialRevertConsecutive: 2
23343
+ };
23344
+ /** How the delivery window is built from the client's reports. */
23345
+ var ADAPTIVE_DELIVERY_CONFIG = {
23346
+ clientSampleSpanMs: 2e3,
23347
+ minWindowMs: 2e3,
23348
+ maxWindowMs: 16e3,
23349
+ maxClientSampleAgeMs: 1e4,
23350
+ historySpanMs: 32e3
23351
+ };
23352
+ //#endregion
23353
+ //#region src/stream-broker/webrtc/climb-capacity.ts
23354
+ /** The client ladder's thresholds (`DOWNLINK_MID_MBPS`, `DOWNLINK_HIGH_MBPS`), kbps. */
23355
+ var UNMEASURED_RUNG_BAR_KBPS = {
23356
+ low: 0,
23357
+ mid: 2500,
23358
+ high: 8e3
23359
+ };
23360
+ /** The kbps the next rung needs; `(1800 × 1.2)` or the client threshold. */
23361
+ function capacityBarKbps(tier, rungKbps, margin) {
23362
+ return rungKbps !== null && rungKbps > 0 ? rungKbps * margin : UNMEASURED_RUNG_BAR_KBPS[tier];
23363
+ }
23364
+ function known(v) {
23365
+ return v !== null && Number.isFinite(v) && v > 0;
23366
+ }
23367
+ function judgeCapacity(evidence, nextTier, nextRungKbps, margin) {
23368
+ const neededKbps = capacityBarKbps(nextTier, nextRungKbps, margin);
23369
+ const { rembKbps, probeKbps, probeFresh } = evidence;
23370
+ if (known(probeKbps) && probeFresh && probeKbps >= neededKbps) return {
23371
+ kind: "sufficient",
23372
+ source: "probe",
23373
+ kbps: probeKbps,
23374
+ neededKbps
23375
+ };
23376
+ if (known(rembKbps) && rembKbps >= neededKbps) return {
23377
+ kind: "sufficient",
23378
+ source: "remb",
23379
+ kbps: rembKbps,
23380
+ neededKbps
23381
+ };
23382
+ if (known(probeKbps) && probeKbps < neededKbps) return {
23383
+ kind: "probe-short",
23384
+ kbps: probeKbps,
23385
+ neededKbps
23386
+ };
23387
+ return {
23388
+ kind: "none",
23389
+ bestKbps: known(rembKbps) ? rembKbps : null,
23390
+ neededKbps
23391
+ };
23392
+ }
23393
+ /**
23394
+ * REMB as it can be trusted. werift stores the LAST received estimate on the
23395
+ * sender and never ages it, so a peer that stopped sending REMB (backgrounded,
23396
+ * gone) would vouch for room indefinitely (review m5). Chrome re-estimates
23397
+ * about once a second and the value moves; an estimate unchanged for
23398
+ * `staleMs` (30 000 ms — fifteen ticks) is treated as no estimate at all.
23399
+ */
23400
+ var RembWatch = class {
23401
+ staleMs;
23402
+ last = null;
23403
+ changedAt = 0;
23404
+ constructor(staleMs) {
23405
+ this.staleMs = staleMs;
23406
+ }
23407
+ read(kbps, now) {
23408
+ if (kbps === null) {
23409
+ this.last = null;
23410
+ return null;
23411
+ }
23412
+ if (kbps !== this.last) {
23413
+ this.last = kbps;
23414
+ this.changedAt = now;
23415
+ }
23416
+ return now - this.changedAt > this.staleMs ? null : kbps;
23417
+ }
23418
+ };
23419
+ //#endregion
23180
23420
  //#region src/stream-broker/webrtc/transcode-egress-leg.ts
23181
23421
  /**
23182
23422
  * The request a browser session makes. Structured only: the primitive's schema
@@ -23291,6 +23531,8 @@ function selectAudioClock(mimeType) {
23291
23531
  *
23292
23532
  * Uses werift (optional peer dependency) for server-side WebRTC.
23293
23533
  */
23534
+ /** A REMB unchanged this long is no estimate (`RembWatch`). */
23535
+ var REMB_STALE_MS = 3e4;
23294
23536
  var _werift;
23295
23537
  async function loadWerift() {
23296
23538
  if (_werift) return _werift;
@@ -24812,6 +25054,7 @@ var AdaptiveSession = class AdaptiveSession {
24812
25054
  this.maybeRebaseRtp(pkt.header.sequenceNumber, pkt.header.timestamp);
24813
25055
  try {
24814
25056
  this.videoSender.sendRtp(pkt);
25057
+ this.videoSendHistory.record(pkt.payload.length, Date.now());
24815
25058
  this.lastVideoRtpTs = pkt.header.timestamp >>> 0;
24816
25059
  if (this.firstVideoRtpTs === null) {
24817
25060
  this.firstVideoRtpTs = this.lastVideoRtpTs;
@@ -25383,6 +25626,26 @@ var AdaptiveSession = class AdaptiveSession {
25383
25626
  get rtpPacketsSentTotal() {
25384
25627
  return this.rtpPacketsSent;
25385
25628
  }
25629
+ /**
25630
+ * Video RTP PAYLOAD bytes this session sent, bucketed by time — the "sent"
25631
+ * half of the adaptive ladder's delivery ratio (D635). Payload, not wire
25632
+ * size, because the client's `bytesReceived` excludes RTP headers.
25633
+ */
25634
+ videoSendHistory = new SendHistory(ADAPTIVE_DELIVERY_CONFIG.historySpanMs);
25635
+ get sentVideoHistory() {
25636
+ return this.videoSendHistory;
25637
+ }
25638
+ /**
25639
+ * The receiver's bandwidth estimate (REMB) in kbps, or `null` when the peer
25640
+ * has sent none. werift stores the last REMB on the sender; `-1`/absent in
25641
+ * the loss line means the same "no estimate", which is not "zero".
25642
+ */
25643
+ receiverEstimateKbps(now) {
25644
+ const raw = this.videoSender?.receiverEstimatedMaxBitrate;
25645
+ return this.rembWatch.read(typeof raw === "bigint" && raw > 0n ? Number(raw) / 1e3 : null, now);
25646
+ }
25647
+ /** Ages the REMB werift keeps forever — see `RembWatch`. */
25648
+ rembWatch = new RembWatch(REMB_STALE_MS);
25386
25649
  rtpLogCounter = 0;
25387
25650
  /** One-shot guards for the "first video/audio RTP" debug markers. */
25388
25651
  firstVideoRtpLogged = false;
@@ -25705,6 +25968,7 @@ var AdaptiveSession = class AdaptiveSession {
25705
25968
  } });
25706
25969
  }
25707
25970
  this.videoSender.sendRtp(pkt);
25971
+ this.videoSendHistory.record(payload.length, Date.now());
25708
25972
  this.rtpPacketsSent++;
25709
25973
  this.logFirstVideoRtp("writeVideoNals", codec);
25710
25974
  if (this.debug && (this.rtpPacketsSent === 1 || this.rtpPacketsSent % 500 === 0)) this.logger.info("RTP packets sent", { meta: {
@@ -26172,6 +26436,42 @@ var LossTracker = class {
26172
26436
  //#endregion
26173
26437
  //#region src/stream-broker/webrtc/stall-tracker.ts
26174
26438
  /**
26439
+ * The one place the device-scoped telemetry is judged ownable. With more than
26440
+ * one adaptive session of the device, the record may be another viewer's:
26441
+ * delivery is unknown ("no telemetry" semantics — the loss path judges) and
26442
+ * the sample says so, so the ladder refuses a try-climb on it.
26443
+ */
26444
+ function buildStallSample(input) {
26445
+ if (input.adaptiveSessionsOnDevice > 1) return {
26446
+ delivery: null,
26447
+ recent: null,
26448
+ clientReporting: false,
26449
+ telemetryShared: true,
26450
+ serverSending: input.serverSending
26451
+ };
26452
+ return {
26453
+ delivery: input.delivery,
26454
+ recent: input.recent,
26455
+ clientReporting: input.clientReporting,
26456
+ telemetryShared: false,
26457
+ serverSending: input.serverSending
26458
+ };
26459
+ }
26460
+ var UNKNOWN_EVIDENCE = {
26461
+ clientKbps: null,
26462
+ sentKbps: null,
26463
+ deliveryRatio: null,
26464
+ deliveryWindowMs: null,
26465
+ stalledForMs: 0,
26466
+ serverSending: false
26467
+ };
26468
+ function ratioOf(delivery) {
26469
+ if (delivery === null) return null;
26470
+ const { receivedKbps, sentKbps } = delivery;
26471
+ if (!Number.isFinite(receivedKbps) || !Number.isFinite(sentKbps) || sentKbps <= 0) return null;
26472
+ return receivedKbps / sentKbps;
26473
+ }
26474
+ /**
26175
26475
  * Windowed "the media stopped arriving" detector. Pure state machine: no
26176
26476
  * clock of its own, no side effects — `observe` is the only mutator and every
26177
26477
  * decision is a function of the samples it was given.
@@ -26181,11 +26481,9 @@ var StallTracker = class {
26181
26481
  /** When the current uninterrupted stall began; null when not stalling. */
26182
26482
  stallStartedAt = null;
26183
26483
  blackoutUntil = 0;
26184
- lastEvidence = {
26185
- clientKbps: null,
26186
- stalledForMs: 0,
26187
- serverSending: false
26188
- };
26484
+ lastEvidence = UNKNOWN_EVIDENCE;
26485
+ clientReporting = false;
26486
+ telemetryShared = false;
26189
26487
  constructor(config) {
26190
26488
  this.config = config;
26191
26489
  }
@@ -26198,38 +26496,36 @@ var StallTracker = class {
26198
26496
  * Fold one evaluation into the window. Returns nothing; ask `isStalled`.
26199
26497
  *
26200
26498
  * Three outcomes, and the middle one is the reason this is not a simple
26201
- * boolean: a rate at or above `recoverKbpsFloor` CLEARS the stall, a rate
26202
- * below `stallKbpsFloor` starts or extends it, and anything between the two
26203
- * (or unknown, or with the server idle) HOLDS whatever the window already
26204
- * believed. Holding is what stops a borderline link from oscillating.
26499
+ * boolean: a ratio at or above `recoverDeliveryRatio` CLEARS the stall, a
26500
+ * ratio below `stallDeliveryRatio` with the server sending starts or extends
26501
+ * it, and anything between the two (or unknown, or nothing sent) HOLDS
26502
+ * whatever the window already believed.
26205
26503
  */
26206
26504
  observe(sample, now) {
26505
+ const ratio = ratioOf(sample.delivery);
26506
+ const measured = (stalledForMs) => ({
26507
+ clientKbps: sample.delivery?.receivedKbps ?? null,
26508
+ sentKbps: sample.delivery?.sentKbps ?? null,
26509
+ deliveryRatio: ratio,
26510
+ deliveryWindowMs: sample.delivery?.windowMs ?? null,
26511
+ stalledForMs,
26512
+ serverSending: sample.serverSending
26513
+ });
26514
+ this.clientReporting = sample.clientReporting;
26515
+ this.telemetryShared = sample.telemetryShared;
26207
26516
  if (now < this.blackoutUntil) {
26208
26517
  this.stallStartedAt = null;
26209
- this.lastEvidence = {
26210
- clientKbps: sample.clientKbps,
26211
- stalledForMs: 0,
26212
- serverSending: sample.serverSending
26213
- };
26518
+ this.lastEvidence = measured(0);
26214
26519
  return;
26215
26520
  }
26216
- const kbps = sample.clientKbps;
26217
- const age = sample.clientSampleAgeMs;
26218
- if (!(kbps !== null && Number.isFinite(kbps) && age !== null && age <= this.config.maxClientSampleAgeMs) || kbps === null) {
26219
- this.lastEvidence = {
26220
- clientKbps: null,
26221
- stalledForMs: this.stalledForMs(now),
26222
- serverSending: sample.serverSending
26223
- };
26224
- return;
26225
- }
26226
- if (kbps >= this.config.recoverKbpsFloor) this.stallStartedAt = null;
26227
- else if (kbps < this.config.stallKbpsFloor && sample.serverSending) this.stallStartedAt ??= now;
26228
- this.lastEvidence = {
26229
- clientKbps: kbps,
26230
- stalledForMs: this.stalledForMs(now),
26231
- serverSending: sample.serverSending
26232
- };
26521
+ const recent = sample.recent !== null && sample.recent.sentKbps >= this.config.freezeMinSentKbps ? ratioOf(sample.recent) : null;
26522
+ const frozen = recent !== null && recent < this.config.freezeDeliveryRatio;
26523
+ const recovered = recent !== null && recent >= this.config.recoverDeliveryRatio;
26524
+ const starved = ratio !== null && ratio < this.config.stallDeliveryRatio && !recovered;
26525
+ const current = recent ?? ratio;
26526
+ if ((frozen || starved) && sample.serverSending) this.stallStartedAt ??= now;
26527
+ else if (current !== null && current >= this.config.recoverDeliveryRatio) this.stallStartedAt = null;
26528
+ this.lastEvidence = measured(this.stalledForMs(now));
26233
26529
  }
26234
26530
  stalledForMs(now) {
26235
26531
  return this.stallStartedAt === null ? 0 : now - this.stallStartedAt;
@@ -26240,25 +26536,42 @@ var StallTracker = class {
26240
26536
  return this.stalledForMs(now) >= this.config.stallConfirmMs;
26241
26537
  }
26242
26538
  /**
26243
- * May the ladder PROMOTE on this evaluation?
26539
+ * May the ladder PROMOTE on this evaluation, as far as the transport knows?
26244
26540
  *
26245
- * False when the last fresh measurement was below `recoverKbpsFloor` —
26246
- * either a stall building, or a link crawling along in the dead band.
26247
- * Promoting there spends bandwidth the session has just been shown not to
26248
- * have, and the loss path cannot object: a stream delivering almost nothing
26249
- * reports almost no loss. Same doctrine as `LossTracker.hasEvidence` — a
26250
- * climb needs POSITIVE evidence, not the absence of a complaint.
26541
+ * No while a stall is building, and no while the rung we are on is not
26542
+ * landing (nearly) whole — promoting there spends bandwidth the session has
26543
+ * just been shown not to have, and the loss path cannot object: a stream
26544
+ * delivering almost nothing reports almost no loss.
26251
26545
  *
26252
- * Unknown does not block. A deployment with no client telemetry at all must
26546
+ * Unknown refuses only while the client IS reporting — the window is simply
26547
+ * not covered yet, and climbing then would promote a link measured at 10 %
26548
+ * a few seconds later. A deployment with no client telemetry at all must
26253
26549
  * behave exactly as it did before this tracker existed; the loss path's own
26254
26550
  * `hasEvidence` is the gate there.
26255
26551
  */
26256
- isClimbSafe(now) {
26257
- if (now < this.blackoutUntil) return true;
26258
- if (this.stallStartedAt !== null) return false;
26259
- const last = this.lastEvidence.clientKbps;
26260
- if (last === null) return true;
26261
- return last >= this.config.recoverKbpsFloor;
26552
+ climbSafety(now) {
26553
+ if (now < this.blackoutUntil) return { safe: true };
26554
+ if (this.stallStartedAt !== null) return {
26555
+ safe: false,
26556
+ reason: "stall-building"
26557
+ };
26558
+ const ratio = this.lastEvidence.deliveryRatio;
26559
+ if (ratio === null) return this.clientReporting ? {
26560
+ safe: false,
26561
+ reason: "delivery"
26562
+ } : { safe: true };
26563
+ return ratio >= this.config.climbDeliveryRatio ? { safe: true } : {
26564
+ safe: false,
26565
+ reason: "delivery"
26566
+ };
26567
+ }
26568
+ /** Was the device's client record possibly another viewer's at the last evaluation? */
26569
+ get isTelemetryShared() {
26570
+ return this.telemetryShared;
26571
+ }
26572
+ /** Was the client reporting at the last evaluation (even if not yet covering a window)? */
26573
+ get isClientReporting() {
26574
+ return this.clientReporting;
26262
26575
  }
26263
26576
  /** What the last `observe` measured — for the switch's log line. */
26264
26577
  evidence(now) {
@@ -26269,9 +26582,142 @@ var StallTracker = class {
26269
26582
  }
26270
26583
  };
26271
26584
  //#endregion
26585
+ //#region src/stream-broker/webrtc/rtt-stability.ts
26586
+ /** 16 s — the longest delivery window, so both halves of a try look at the same span. */
26587
+ var RTT_WINDOW_MS = 16e3;
26588
+ var MIN_SAMPLES = 3;
26589
+ var MIN_SPREAD_MS = 20;
26590
+ var SPREAD_FRACTION = .5;
26591
+ var RttWindow = class {
26592
+ samples = [];
26593
+ observe(rttMs, now) {
26594
+ if (rttMs === null || !Number.isFinite(rttMs) || rttMs <= 0) return;
26595
+ this.samples = [...this.samples.filter((s) => now - s.at <= RTT_WINDOW_MS), {
26596
+ rttMs,
26597
+ at: now
26598
+ }];
26599
+ }
26600
+ stability(now) {
26601
+ const recent = this.samples.filter((s) => now - s.at <= RTT_WINDOW_MS);
26602
+ if (recent.length < MIN_SAMPLES) return "unknown";
26603
+ const values = recent.map((s) => s.rttMs);
26604
+ const min = Math.min(...values);
26605
+ return Math.max(...values) - min <= Math.max(MIN_SPREAD_MS, min * SPREAD_FRACTION) ? "stable" : "unstable";
26606
+ }
26607
+ };
26608
+ //#endregion
26609
+ //#region src/stream-broker/webrtc/try-climb.ts
26610
+ var NO_TRY_HISTORY = {
26611
+ failures: 0,
26612
+ notBefore: 0,
26613
+ lastRevert: null,
26614
+ trialFrom: null
26615
+ };
26616
+ /** Backoff after the Nth consecutive failed try: base × 2^(N−1), capped. */
26617
+ function tryBackoffMs(failures, baseMs, maxMs) {
26618
+ if (failures <= 0) return 0;
26619
+ return Math.min(baseMs * 2 ** (failures - 1), maxMs);
26620
+ }
26621
+ /** Watches one tried rung through its first window. Pure: fed, never reads a clock. */
26622
+ var TrialWatch = class {
26623
+ from;
26624
+ config;
26625
+ startedAt = null;
26626
+ lowStreak = 0;
26627
+ measured = 0;
26628
+ constructor(from, config) {
26629
+ this.from = from;
26630
+ this.config = config;
26631
+ }
26632
+ /**
26633
+ * Start the trial over: it must be judged afresh on live media. Called when
26634
+ * recorded playback takes the track (review m14) — time off live must never
26635
+ * count toward keeping a rung that was never measured.
26636
+ */
26637
+ restart() {
26638
+ this.startedAt = null;
26639
+ this.lowStreak = 0;
26640
+ this.measured = 0;
26641
+ }
26642
+ judge(now, input) {
26643
+ this.startedAt ??= now;
26644
+ if (input.stalled || input.lossFailing) return {
26645
+ kind: "revert",
26646
+ deliveryRatio: input.deliveryRatio,
26647
+ stalled: input.stalled
26648
+ };
26649
+ if (input.deliveryRatio !== null) {
26650
+ this.measured += 1;
26651
+ this.lowStreak = input.deliveryRatio < this.config.trialRevertRatio ? this.lowStreak + 1 : 0;
26652
+ if (this.lowStreak >= this.config.trialRevertConsecutive) return {
26653
+ kind: "revert",
26654
+ deliveryRatio: input.deliveryRatio,
26655
+ stalled: false
26656
+ };
26657
+ }
26658
+ const forMs = now - this.startedAt;
26659
+ const judged = this.measured >= this.config.trialRevertConsecutive || !input.clientReporting;
26660
+ if (forMs >= this.config.trialMs && judged || forMs >= this.config.trialMaxMs) return { kind: "kept" };
26661
+ return {
26662
+ kind: "running",
26663
+ forMs
26664
+ };
26665
+ }
26666
+ };
26667
+ /**
26668
+ * The try-climb memory per DEVICE on this broker, with a TTL (review I4).
26669
+ * The per-session intent survives only a re-offer; without this, every new
26670
+ * viewer session of a camera whose next rung keeps failing started again at
26671
+ * try #1. The trial itself (`trialFrom`) never carries to a new session.
26672
+ */
26673
+ var DeviceTryMemory = class {
26674
+ ttlMs;
26675
+ entries = /* @__PURE__ */ new Map();
26676
+ /** @param ttlMs forget a device not refreshed for this long — 600 000 ms, the backoff cap. */
26677
+ constructor(ttlMs) {
26678
+ this.ttlMs = ttlMs;
26679
+ }
26680
+ set(deviceId, carry, now) {
26681
+ this.entries.set(deviceId, {
26682
+ carry,
26683
+ at: now
26684
+ });
26685
+ }
26686
+ get(deviceId, now) {
26687
+ const entry = this.entries.get(deviceId);
26688
+ if (entry === void 0) return null;
26689
+ if (now - entry.at > this.ttlMs) {
26690
+ this.entries.delete(deviceId);
26691
+ return null;
26692
+ }
26693
+ return {
26694
+ ...entry.carry,
26695
+ trialFrom: null
26696
+ };
26697
+ }
26698
+ };
26699
+ //#endregion
26272
26700
  //#region src/stream-broker/webrtc/adaptive-controller.ts
26273
26701
  var TIER_ORDER = CAM_PROFILE_ORDER;
26274
26702
  /**
26703
+ * The cause as the wire (`WebrtcLadderDecision.cause`) can carry it. The wire
26704
+ * enum predates the try-climb, and widening it would make a server built
26705
+ * before it reject this broker's debug answer (the router validates output).
26706
+ * A trial revert is reported as what undelivered bytes are — `stall` when the
26707
+ * stall confirmed, else `loss` — and the climb hold carries the exact account
26708
+ * ("tried mid, reverted: delivery 0.60").
26709
+ */
26710
+ function wireLadderCause(decision, stallConfirmMs) {
26711
+ if (decision.cause !== "trial-revert") return { cause: decision.cause };
26712
+ return {
26713
+ cause: decision.stall.stalledForMs >= stallConfirmMs ? "stall" : "loss",
26714
+ causeDetail: "trial-revert"
26715
+ };
26716
+ }
26717
+ function positiveOrNull(v) {
26718
+ return v !== null && Number.isFinite(v) && v > 0 ? v : null;
26719
+ }
26720
+ /**
26275
26721
  * Ladder state machine for an adaptive WebRTC session. Fed loss samples
26276
26722
  * (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
26277
26723
  * Downgrade order: tier high→mid→low, then MTU reduction at low, then
@@ -26285,7 +26731,16 @@ var TIER_ORDER = CAM_PROFILE_ORDER;
26285
26731
  * a session that has produced no RTCP at all is not "healthy", it is
26286
26732
  * silent, and climbing on silence spends bandwidth never shown to exist;
26287
26733
  * 3. the target tier's re-attempt backoff, which doubles per measured
26288
- * failure of that tier in this session.
26734
+ * failure of that tier in this session;
26735
+ * 4. the transport showing the CURRENT rung landing whole
26736
+ * (`StallTracker.climbSafety`, a delivery ratio over one GOP — D635);
26737
+ * 5. capacity: evidence (REMB or the viewer's probe) clearing the next
26738
+ * rung's bar confirms the climb; a MEASURED probe below it holds; with no
26739
+ * evidence either way the ladder TRIES the rung on a clean path (delivery
26740
+ * ≥ 0.95, loss < 1 %, RTT stable) and reverts within the rung's first
26741
+ * window if it does not land, backing off 30 s → 10 min (D635, round 2).
26742
+ *
26743
+ * Every tick that does not promote leaves a {@link ClimbHold} naming why.
26289
26744
  */
26290
26745
  var AdaptiveController = class {
26291
26746
  opts;
@@ -26306,6 +26761,16 @@ var AdaptiveController = class {
26306
26761
  armed = false;
26307
26762
  failures;
26308
26763
  upgradeCount;
26764
+ capacity = {
26765
+ rembKbps: null,
26766
+ probeKbps: null,
26767
+ probeFresh: false
26768
+ };
26769
+ rtt = new RttWindow();
26770
+ tryCarry;
26771
+ /** Non-null while the current rung is a trial. */
26772
+ trial;
26773
+ hold = null;
26309
26774
  constructor(opts) {
26310
26775
  this.opts = opts;
26311
26776
  this.tracker = new LossTracker(opts.config);
@@ -26313,6 +26778,16 @@ var AdaptiveController = class {
26313
26778
  this.tier = opts.initialTier;
26314
26779
  this.failures = new Map(opts.priorFailures ?? []);
26315
26780
  this.upgradeCount = Math.max(0, opts.priorUpgradeCount ?? 0);
26781
+ const carried = opts.tryClimb ?? NO_TRY_HISTORY;
26782
+ this.tryCarry = carried.trialFrom === opts.initialTier ? {
26783
+ ...carried,
26784
+ trialFrom: null
26785
+ } : carried;
26786
+ this.trial = this.tryCarry.trialFrom !== null ? new TrialWatch(this.tryCarry.trialFrom, opts.config) : null;
26787
+ }
26788
+ /** What the NEXT controller of this viewer session must inherit about tries. */
26789
+ get tryClimbCarry() {
26790
+ return this.tryCarry;
26316
26791
  }
26317
26792
  get currentTier() {
26318
26793
  return this.tier;
@@ -26345,6 +26820,27 @@ var AdaptiveController = class {
26345
26820
  this.stalls.observe(sample, now);
26346
26821
  }
26347
26822
  /**
26823
+ * The capacity witnesses, kbps — `null` when unknown: the receiver's REMB
26824
+ * (capped by what is sent, so evidence only FOR room) and the viewer's
26825
+ * downlink probe for this session (a path measurement, evidence both ways).
26826
+ * See `climb-capacity.ts`.
26827
+ */
26828
+ onCapacity(evidence) {
26829
+ this.capacity = {
26830
+ rembKbps: positiveOrNull(evidence.rembKbps),
26831
+ probeKbps: positiveOrNull(evidence.probeKbps),
26832
+ probeFresh: evidence.probeFresh
26833
+ };
26834
+ }
26835
+ /** The client's round-trip time, ms; null/0 = unknown. One of a try's conditions. */
26836
+ onRtt(rttMs, now) {
26837
+ this.rtt.observe(rttMs, now);
26838
+ }
26839
+ /** Why the ladder did not climb on the last evaluation; null = at the top, or it just moved. */
26840
+ get climbHold() {
26841
+ return this.hold;
26842
+ }
26843
+ /**
26348
26844
  * Freeze the ladder for this evaluation instead of ticking it.
26349
26845
  *
26350
26846
  * Called while the session's track is NOT carrying the live source — D42
@@ -26365,6 +26861,8 @@ var AdaptiveController = class {
26365
26861
  this.lastChangeAt = now;
26366
26862
  }
26367
26863
  this.healthySince = now;
26864
+ this.trial?.restart();
26865
+ this.setHold("suspended", now);
26368
26866
  this.stalls.markChanged(now);
26369
26867
  }
26370
26868
  tick(now) {
@@ -26373,26 +26871,132 @@ var AdaptiveController = class {
26373
26871
  this.healthySince = now;
26374
26872
  this.lastChangeAt = now;
26375
26873
  }
26874
+ if (this.trial !== null && this.judgeTrial(now)) return;
26376
26875
  if (this.stalls.isStalled(now)) {
26377
26876
  this.healthySince = now;
26877
+ this.hold = null;
26378
26878
  this.handleFailing(now, "stall");
26379
26879
  return;
26380
26880
  }
26381
26881
  if (this.tracker.isFailing(now)) {
26382
26882
  this.healthySince = now;
26883
+ this.hold = null;
26383
26884
  this.handleFailing(now, "loss");
26384
26885
  return;
26385
26886
  }
26386
- if (!this.stalls.isClimbSafe(now)) {
26887
+ const safety = this.stalls.climbSafety(now);
26888
+ if (!safety.safe) {
26387
26889
  this.healthySince = now;
26890
+ this.setHold(safety.reason, now);
26388
26891
  return;
26389
26892
  }
26390
26893
  if (!this.tracker.hasEvidence(now)) {
26391
26894
  this.healthySince = now;
26895
+ this.setHold("no-evidence", now);
26392
26896
  return;
26393
26897
  }
26394
26898
  this.handleHealthy(now);
26395
26899
  }
26900
+ nextTier() {
26901
+ const idx = TIER_ORDER.indexOf(this.tier);
26902
+ return idx <= 0 ? null : TIER_ORDER[idx - 1] ?? null;
26903
+ }
26904
+ /**
26905
+ * Record why this evaluation did not climb. At the top rung there is nothing
26906
+ * to hold — except a `trial`, which names the rung being tried.
26907
+ */
26908
+ setHold(code, now, extra = {}) {
26909
+ const next = code === "trial" ? this.tier : this.nextTier();
26910
+ if (next === null) {
26911
+ this.hold = null;
26912
+ return;
26913
+ }
26914
+ const ev = this.stalls.evidence(now);
26915
+ const clockRunning = code === "warming" || code === "cooldown" || code === "capacity" || code === "try-unclean" || code === "try-backoff";
26916
+ const nextTierKbps = this.opts.expectedTierKbps?.(next) ?? null;
26917
+ this.hold = {
26918
+ code,
26919
+ nextTier: next,
26920
+ deliveryRatio: ev.deliveryRatio,
26921
+ deliveryWindowMs: ev.deliveryWindowMs,
26922
+ climbDeliveryRatio: this.opts.config.climbDeliveryRatio,
26923
+ capacityKbps: this.capacity.rembKbps,
26924
+ probeKbps: this.capacity.probeKbps,
26925
+ nextTierKbps,
26926
+ capacityMargin: this.opts.config.climbCapacityMargin,
26927
+ neededKbps: capacityBarKbps(next, nextTierKbps, this.opts.config.climbCapacityMargin),
26928
+ tryBlockedBy: extra.tryBlockedBy ?? null,
26929
+ tryDeliveryRatio: this.opts.config.tryClimbDeliveryRatio,
26930
+ tryMaxLossRatio: this.opts.config.tryClimbMaxLossRatio,
26931
+ retryInMs: code === "try-backoff" ? Math.max(0, this.tryCarry.notBefore - now) : null,
26932
+ lastRevert: this.tryCarry.lastRevert,
26933
+ trialForMs: code === "trial" ? extra.trialForMs ?? 0 : null,
26934
+ trialMs: code === "trial" ? this.opts.config.trialMs : null,
26935
+ healthyForMs: clockRunning ? now - this.healthySince : null,
26936
+ requiredHealthyMs: this.requiredHealthyMs(next),
26937
+ since: this.hold?.code === code ? this.hold.since : now
26938
+ };
26939
+ }
26940
+ /**
26941
+ * Judge the rung on trial. Returns true when this evaluation is consumed
26942
+ * (still running, or reverted); false when the trial was just kept and the
26943
+ * ordinary ladder takes over.
26944
+ */
26945
+ judgeTrial(now) {
26946
+ const watch = this.trial;
26947
+ if (watch === null) return false;
26948
+ const verdict = watch.judge(now, {
26949
+ deliveryRatio: this.stalls.evidence(now).deliveryRatio,
26950
+ clientReporting: this.stalls.isClientReporting,
26951
+ stalled: this.stalls.isStalled(now),
26952
+ lossFailing: this.tracker.isFailing(now)
26953
+ });
26954
+ if (verdict.kind === "running") {
26955
+ this.healthySince = now;
26956
+ this.setHold("trial", now, { trialForMs: verdict.forMs });
26957
+ return true;
26958
+ }
26959
+ this.trial = null;
26960
+ if (verdict.kind === "kept") {
26961
+ this.tryCarry = NO_TRY_HISTORY;
26962
+ this.healthySince = now;
26963
+ return false;
26964
+ }
26965
+ const failures = this.tryCarry.failures + 1;
26966
+ const tried = this.tier;
26967
+ this.tryCarry = {
26968
+ failures,
26969
+ notBefore: now + tryBackoffMs(failures, this.opts.config.tryBackoffBaseMs, this.opts.config.tryBackoffMaxMs),
26970
+ lastRevert: {
26971
+ tier: tried,
26972
+ deliveryRatio: verdict.deliveryRatio,
26973
+ stalled: verdict.stalled,
26974
+ at: now
26975
+ },
26976
+ trialFrom: null
26977
+ };
26978
+ this.hold = null;
26979
+ this.tier = watch.from;
26980
+ this.mtuReduced = false;
26981
+ this.transcodeEngaged = false;
26982
+ this.opts.onSwitchTier(watch.from, this.decisionAt(now, "trial-revert"));
26983
+ this.commitChange(now);
26984
+ this.healthySince = now;
26985
+ return true;
26986
+ }
26987
+ /**
26988
+ * Which clean-path condition refuses a TRY, or null when the path is clean.
26989
+ * A client that sends no telemetry at all cannot measure delivery; there the
26990
+ * loss path is the judge, as it was before delivery existed.
26991
+ */
26992
+ tryBlocker(now) {
26993
+ if (this.stalls.isTelemetryShared) return "shared-telemetry";
26994
+ const ratio = this.stalls.evidence(now).deliveryRatio;
26995
+ if (ratio === null ? this.stalls.isClientReporting : ratio < this.opts.config.tryClimbDeliveryRatio) return "delivery";
26996
+ if (this.tracker.lossRatio() >= this.opts.config.tryClimbMaxLossRatio) return "loss";
26997
+ if (this.rtt.stability(now) === "unstable") return "rtt";
26998
+ return null;
26999
+ }
26396
27000
  canUpgrade(now) {
26397
27001
  return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
26398
27002
  }
@@ -26413,11 +27017,12 @@ var AdaptiveController = class {
26413
27017
  this.stalls.markChanged(now);
26414
27018
  }
26415
27019
  /** The measurement this decision rests on, snapshotted for the log line. */
26416
- decisionAt(now, cause) {
27020
+ decisionAt(now, cause, trial = false) {
26417
27021
  return {
26418
27022
  cause,
26419
27023
  lossRatio: this.tracker.lossRatio(),
26420
- stall: this.stalls.evidence(now)
27024
+ stall: this.stalls.evidence(now),
27025
+ trial
26421
27026
  };
26422
27027
  }
26423
27028
  handleFailing(now, cause) {
@@ -26444,21 +27049,223 @@ var AdaptiveController = class {
26444
27049
  }
26445
27050
  }
26446
27051
  handleHealthy(now) {
26447
- const idx = TIER_ORDER.indexOf(this.tier);
26448
- if (idx <= 0) return;
26449
- const next = TIER_ORDER[idx - 1];
26450
- if (now - this.healthySince < this.requiredHealthyMs(next)) return;
26451
- if (!this.canUpgrade(now)) return;
27052
+ const next = this.nextTier();
27053
+ if (next === null) {
27054
+ this.hold = null;
27055
+ return;
27056
+ }
27057
+ const capacity = judgeCapacity(this.capacity, next, this.opts.expectedTierKbps?.(next) ?? null, this.opts.config.climbCapacityMargin);
27058
+ if (capacity.kind === "probe-short") {
27059
+ this.setHold("capacity", now);
27060
+ return;
27061
+ }
27062
+ if (capacity.kind === "none" && now < this.tryCarry.notBefore) {
27063
+ this.setHold("try-backoff", now);
27064
+ return;
27065
+ }
27066
+ if (now - this.healthySince < this.requiredHealthyMs(next)) {
27067
+ this.setHold("warming", now);
27068
+ return;
27069
+ }
27070
+ if (!this.canUpgrade(now)) {
27071
+ this.setHold("cooldown", now);
27072
+ return;
27073
+ }
27074
+ if (capacity.kind === "sufficient") {
27075
+ this.promote(now, next, false);
27076
+ return;
27077
+ }
27078
+ const blocker = this.tryBlocker(now);
27079
+ if (blocker !== null) {
27080
+ this.setHold("try-unclean", now, { tryBlockedBy: blocker });
27081
+ return;
27082
+ }
27083
+ this.promote(now, next, true);
27084
+ }
27085
+ promote(now, next, trial) {
27086
+ const from = this.tier;
27087
+ this.hold = null;
26452
27088
  this.tier = next;
26453
27089
  this.mtuReduced = false;
26454
27090
  this.transcodeEngaged = false;
26455
27091
  this.upgradeCount += 1;
26456
- this.opts.onSwitchTier(next, this.decisionAt(now, "healthy"));
27092
+ this.tryCarry = {
27093
+ ...this.tryCarry,
27094
+ trialFrom: trial ? from : null
27095
+ };
27096
+ this.trial = trial ? new TrialWatch(from, this.opts.config) : null;
27097
+ this.opts.onSwitchTier(next, this.decisionAt(now, "healthy", trial));
26457
27098
  this.commitChange(now);
26458
27099
  this.healthySince = now;
26459
27100
  }
26460
27101
  };
26461
27102
  //#endregion
27103
+ //#region src/stream-broker/webrtc/climb-hold.ts
27104
+ /** The wire shape: the hold plus the line the broker logs. */
27105
+ function toWireClimbHold(h) {
27106
+ return {
27107
+ ...h,
27108
+ summary: describeClimbHold(h)
27109
+ };
27110
+ }
27111
+ function seconds(ms) {
27112
+ return `${(ms / 1e3).toFixed(1)}s`;
27113
+ }
27114
+ /** A rate, or "unknown" — never a fabricated 0 kbps (D393). */
27115
+ function kbps(v) {
27116
+ return v === null ? "unknown" : `${String(Math.round(v))} kbps`;
27117
+ }
27118
+ function deliveryPart(h) {
27119
+ if (h.deliveryRatio === null || h.deliveryWindowMs === null) return null;
27120
+ return `delivery ${h.deliveryRatio.toFixed(2)} over ${seconds(h.deliveryWindowMs)}`;
27121
+ }
27122
+ /** `mid needs 2160 kbps (1800 × 1.2)`, or `(client threshold)` for an unmeasured rung. */
27123
+ function barPart(h) {
27124
+ const basis = h.nextTierKbps !== null && h.nextTierKbps > 0 ? `(${String(Math.round(h.nextTierKbps))} × ${String(h.capacityMargin)})` : "(client threshold)";
27125
+ return `${h.nextTier} needs ${kbps(h.neededKbps)} ${basis}`;
27126
+ }
27127
+ function witnesses(h) {
27128
+ return `${h.capacityKbps === null ? "no REMB" : `REMB ${kbps(h.capacityKbps)}`}, ${h.probeKbps === null ? "no probe" : `probe ${kbps(h.probeKbps)}`}`;
27129
+ }
27130
+ function blockerPart(h) {
27131
+ switch (h.tryBlockedBy) {
27132
+ case "delivery": return h.deliveryRatio === null ? "delivery not measured" : `delivery ${h.deliveryRatio.toFixed(2)} < ${h.tryDeliveryRatio.toFixed(2)}`;
27133
+ case "loss": return `loss ≥ ${String(Math.round(h.tryMaxLossRatio * 1e3) / 10)}%`;
27134
+ case "rtt": return "RTT unstable";
27135
+ case "shared-telemetry": return "another viewer shares this camera's telemetry";
27136
+ case null: return "unknown";
27137
+ }
27138
+ }
27139
+ function revertPart(h) {
27140
+ const r = h.lastRevert;
27141
+ if (r === null) return "a try reverted";
27142
+ const why = r.stalled ? "stall" : r.deliveryRatio === null ? "loss" : `delivery ${r.deliveryRatio.toFixed(2)}`;
27143
+ return `tried ${r.tier}, reverted: ${why}`;
27144
+ }
27145
+ /**
27146
+ * One line, English, numbers included — the same text the broker logs and the
27147
+ * viewer shows under its localized hold label. Every number is one the
27148
+ * controller actually used; an unknown one is said to be unknown.
27149
+ */
27150
+ function describeClimbHold(h) {
27151
+ const delivery = deliveryPart(h);
27152
+ switch (h.code) {
27153
+ case "stall-building": return `held: stall building (${delivery ?? "delivery unknown"})`;
27154
+ case "delivery": return delivery === null ? "held: delivery not yet measured over one GOP" : `held: ${delivery} < ${h.climbDeliveryRatio.toFixed(2)}`;
27155
+ case "no-evidence": return "held: no receiver feedback in the loss window";
27156
+ case "warming": return `climbing: healthy ${seconds(h.healthyForMs ?? 0)} of ${seconds(h.requiredHealthyMs ?? 0)} before ${h.nextTier}`;
27157
+ case "cooldown": return "held: too soon after the last change";
27158
+ case "capacity": return `held: probe ${kbps(h.probeKbps)}, ${h.capacityKbps === null ? "no REMB" : `REMB ${kbps(h.capacityKbps)}`} < ${barPart(h)}`;
27159
+ case "try-unclean": return `held: no capacity evidence for ${h.nextTier} (${witnesses(h)}); not clean enough to try: ${blockerPart(h)}`;
27160
+ case "try-backoff": return `held: ${revertPart(h)}; next try in ${seconds(h.retryInMs ?? 0)}`;
27161
+ case "trial": return `trying ${h.nextTier}: ${seconds(h.trialForMs ?? 0)} of ${seconds(h.trialMs ?? 0)} (reverts if it does not land)`;
27162
+ case "suspended": return "held: recorded playback on the track (ladder suspended)";
27163
+ }
27164
+ }
27165
+ /**
27166
+ * Which hold changes deserve a log line (review m3). A code logs when it
27167
+ * differs from the last one logged AND has not itself been logged within
27168
+ * `quietMs` — a link hovering at 0.90–0.95 delivery cycles delivery →
27169
+ * warming → try-unclean and used to write a line every 4–8 s for ever.
27170
+ * Progress (`warming`, `trial`) never logs; the switch line covers moves.
27171
+ */
27172
+ var HoldLogGate = class {
27173
+ quietMs;
27174
+ last = null;
27175
+ loggedAt = /* @__PURE__ */ new Map();
27176
+ constructor(quietMs) {
27177
+ this.quietMs = quietMs;
27178
+ }
27179
+ shouldLog(code, now) {
27180
+ if (code === "warming" || code === "trial") return false;
27181
+ if (code === this.last) return false;
27182
+ const at = this.loggedAt.get(code);
27183
+ if (at !== void 0 && now - at < this.quietMs) return false;
27184
+ this.last = code;
27185
+ this.loggedAt.set(code, now);
27186
+ return true;
27187
+ }
27188
+ };
27189
+ //#endregion
27190
+ //#region src/stream-broker/webrtc/rung-bitrate.ts
27191
+ /**
27192
+ * Shortest lifetime whose average means anything: 4 000 ms ≈ one GOP on the
27193
+ * cameras this hub serves; less than that is one IDR divided by nothing.
27194
+ */
27195
+ var MIN_AVERAGE_UPTIME_MS = 4e3;
27196
+ function expectedRungKbps(stats) {
27197
+ const lifetime = stats.totalBytes > 0 && stats.uptimeMs >= MIN_AVERAGE_UPTIME_MS ? Math.round(stats.totalBytes * 8 / stats.uptimeMs) : null;
27198
+ const windowKbps = stats.status === "streaming" && stats.bitrateKbps > 0 ? stats.bitrateKbps : null;
27199
+ if (windowKbps === null) return lifetime;
27200
+ return lifetime === null ? windowKbps : Math.max(windowKbps, lifetime);
27201
+ }
27202
+ /** The client's probe cache TTL (`DOWNLINK_TTL_MS`): the oldest a hinted probe can be. */
27203
+ var HINT_PROBE_MAX_AGE_MS = 3e4;
27204
+ function mbpsToKbps(mbps) {
27205
+ return mbps !== void 0 && Number.isFinite(mbps) && mbps > 0 ? mbps * 1e3 : null;
27206
+ }
27207
+ /** The probe an offer carried, dated conservatively; null when it carried none. */
27208
+ function probeFromHints(downlinkMbps, now) {
27209
+ const kbps = mbpsToKbps(downlinkMbps);
27210
+ return kbps === null ? null : {
27211
+ kbps,
27212
+ at: now - HINT_PROBE_MAX_AGE_MS
27213
+ };
27214
+ }
27215
+ /** The probe a telemetry report carried; null when it carried none. */
27216
+ function probeFromReport(report, now) {
27217
+ const kbps = mbpsToKbps(report.downlinkMbps);
27218
+ if (kbps === null) return null;
27219
+ return {
27220
+ kbps,
27221
+ at: now - Math.max(0, report.reportAgeMs) - Math.max(0, report.downlinkAgeMs)
27222
+ };
27223
+ }
27224
+ /** The more recent of two probes (either may be absent). */
27225
+ function newerProbe(a, b) {
27226
+ if (a === null) return b;
27227
+ if (b === null) return a;
27228
+ return b.at >= a.at ? b : a;
27229
+ }
27230
+ function isProbeFresh(probe, now) {
27231
+ return probe !== null && now - probe.at <= 12e4;
27232
+ }
27233
+ /**
27234
+ * How long the broker keeps a device's probe at all. 600 000 ms (10 min, the
27235
+ * try-backoff cap). A SHORT probe holds a climb until a newer probe replaces
27236
+ * it — and a viewer that cannot probe (no token yet, a share link, whose token
27237
+ * is not session-grade) never supplies one, so without an end one viewer's
27238
+ * 1.2 Mbps reading would hold every such viewer of the camera on `capacity`
27239
+ * until the broker restarted (review N1).
27240
+ */
27241
+ var PROBE_MEMORY_TTL_MS = 6e5;
27242
+ /** The newest probe per device, bounded in time and forgotten with the device's last session. */
27243
+ var DeviceProbeMemory = class {
27244
+ ttlMs;
27245
+ probes = /* @__PURE__ */ new Map();
27246
+ constructor(ttlMs) {
27247
+ this.ttlMs = ttlMs;
27248
+ }
27249
+ /** Keep `probe` if it is newer than what is held; an absent one changes nothing. */
27250
+ record(deviceId, probe) {
27251
+ const kept = newerProbe(this.probes.get(deviceId) ?? null, probe);
27252
+ if (kept !== null) this.probes.set(deviceId, kept);
27253
+ }
27254
+ get(deviceId, now) {
27255
+ const probe = this.probes.get(deviceId);
27256
+ if (probe === void 0) return null;
27257
+ if (now - probe.at > this.ttlMs) {
27258
+ this.probes.delete(deviceId);
27259
+ return null;
27260
+ }
27261
+ return probe;
27262
+ }
27263
+ /** The device's last adaptive session ended: nobody's probe speaks for the next viewer. */
27264
+ forget(deviceId) {
27265
+ this.probes.delete(deviceId);
27266
+ }
27267
+ };
27268
+ //#endregion
26462
27269
  //#region src/stream-broker/webrtc/session-debug.ts
26463
27270
  /** `<deviceId>/<camStreamId>` → the camStreamId, or null when it is not one. */
26464
27271
  function camStreamIdOf(brokerId) {
@@ -26497,7 +27304,8 @@ var DEAD = {
26497
27304
  trackMode: null,
26498
27305
  lastDecision: null,
26499
27306
  tierFailures: [],
26500
- upgradeCount: null
27307
+ upgradeCount: null,
27308
+ climbHold: null
26501
27309
  };
26502
27310
  function projectSessionDebug(input) {
26503
27311
  const { session } = input;
@@ -26534,7 +27342,8 @@ function projectSessionDebug(input) {
26534
27342
  tier,
26535
27343
  failures
26536
27344
  })),
26537
- upgradeCount: input.ladder?.upgradeCount ?? null
27345
+ upgradeCount: input.ladder?.upgradeCount ?? null,
27346
+ climbHold: input.ladder?.climbHold ?? null
26538
27347
  };
26539
27348
  }
26540
27349
  var PLAYBACK_PROFILE_FALLBACK_ORDER = [
@@ -33511,24 +34320,13 @@ function pickAdaptiveTier(input) {
33511
34320
  *
33512
34321
  * Flow: Camera → RTSP → Broker → EncodedPacket → RTP packetization → WebRTC
33513
34322
  */
33514
- /** How often the adaptive controller re-evaluates the ladder (ms). */
33515
- var ADAPTIVE_TICK_MS = 2e3;
33516
- /** Ladder tuning shared by every adaptive session. */
33517
- var ADAPTIVE_CONFIG = {
33518
- analyzeWindowMs: 15e3,
33519
- minFailPercent: .05,
33520
- waitAfterResetMs: 8e3,
33521
- upgradeHealthyMs: 4e3,
33522
- upgradeHealthyGrowthFactor: 3,
33523
- maxProgressiveUpgradeHealthyMs: 3e4,
33524
- minTimeBetweenChangesMs: 4e3,
33525
- maxUpgradeBackoffMs: 9e5,
33526
- stallKbpsFloor: 64,
33527
- stallConfirmMs: 6e3,
33528
- recoverKbpsFloor: 128,
33529
- waitAfterChangeMs: 8e3,
33530
- maxClientSampleAgeMs: 1e4
33531
- };
34323
+ /**
34324
+ * A hold code that has been logged stays quiet this long before it may log
34325
+ * again (`HoldLogGate`). 60 000 ms: a hovering link changes code every 4–8 s.
34326
+ */
34327
+ var HOLD_LOG_QUIET_MS = 6e4;
34328
+ /** A failing adaptive tick logs at most once per this long, per session. */
34329
+ var TICK_FAILURE_LOG_QUIET_MS = 6e4;
33532
34330
  /**
33533
34331
  * True when `broker` is an in-process StreamBroker exposing the Opus egress
33534
34332
  * controls. Uses `in` narrowing + `typeof` checks (no cast) so the returned
@@ -33765,6 +34563,14 @@ var BrokerWebrtcServer = class {
33765
34563
  */
33766
34564
  lastLadderDecision = /* @__PURE__ */ new Map();
33767
34565
  /**
34566
+ * Try-climb memory per DEVICE (review I4): a new viewer session of a camera
34567
+ * whose next rung keeps failing inherits the backoff instead of starting at
34568
+ * try #1. Forgotten after the backoff cap without a refresh.
34569
+ */
34570
+ deviceTryMemory = new DeviceTryMemory(ADAPTIVE_CONFIG.tryBackoffMaxMs);
34571
+ /** The newest viewer probe per device, from offers and telemetry (`probe-evidence.ts`). */
34572
+ deviceProbe = new DeviceProbeMemory(PROBE_MEMORY_TTL_MS);
34573
+ /**
33768
34574
  * brokerId → where its media is ingested. Injected by the manager, which
33769
34575
  * owns the ownership map; unset (or a null answer) means UNRESOLVED, and
33770
34576
  * the overlay says so rather than naming the local node.
@@ -34762,7 +35568,8 @@ var BrokerWebrtcServer = class {
34762
35568
  };
34763
35569
  if (streamId.endsWith("/adaptive")) {
34764
35570
  const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
34765
- this.startAdaptive(entry, deviceId, initialTier, sessionId, intent?.failures, intent?.upgradeCount);
35571
+ this.recordDeviceProbe(deviceId, probeFromHints(hints.downlinkMbps, Date.now()));
35572
+ this.startAdaptive(entry, deviceId, initialTier, sessionId, intent);
34766
35573
  }
34767
35574
  return {
34768
35575
  sessionId,
@@ -34880,11 +35687,13 @@ var BrokerWebrtcServer = class {
34880
35687
  const ladder = controller !== void 0 ? {
34881
35688
  lastDecision: decision,
34882
35689
  failures: [...controller.failureCounts],
34883
- upgradeCount: controller.successfulUpgradeCount
35690
+ upgradeCount: controller.successfulUpgradeCount,
35691
+ climbHold: controller.climbHold === null ? null : toWireClimbHold(controller.climbHold)
34884
35692
  } : decision !== null ? {
34885
35693
  lastDecision: decision,
34886
35694
  failures: [],
34887
- upgradeCount: 0
35695
+ upgradeCount: 0,
35696
+ climbHold: null
34888
35697
  } : null;
34889
35698
  let brokerStats = null;
34890
35699
  try {
@@ -34927,7 +35736,7 @@ var BrokerWebrtcServer = class {
34927
35736
  /** Snapshot the ladder's move so the overlay can name it later. */
34928
35737
  recordLadderDecision(sessionId, decision, tier, forcedTranscodeDown) {
34929
35738
  this.lastLadderDecision.set(sessionId, {
34930
- cause: decision.cause,
35739
+ ...wireLadderCause(decision, ADAPTIVE_CONFIG.stallConfirmMs),
34931
35740
  tier,
34932
35741
  forcedTranscodeDown,
34933
35742
  lossRatio: decision.lossRatio,
@@ -34938,6 +35747,39 @@ var BrokerWebrtcServer = class {
34938
35747
  });
34939
35748
  }
34940
35749
  /**
35750
+ * The IDR interval the broker feeding a session measured, or null when it
35751
+ * has measured none (a fresh broker reports 0). Sizes the delivery window.
35752
+ */
35753
+ brokerIdrIntervalMs(brokerId) {
35754
+ try {
35755
+ const idr = this.brokers.get(brokerId)?.getStats().idrIntervalMs ?? null;
35756
+ return idr !== null && idr > 0 ? idr : null;
35757
+ } catch {
35758
+ return null;
35759
+ }
35760
+ }
35761
+ /**
35762
+ * The expected bitrate of `tier` for this device, from the broker serving
35763
+ * that rung — null when no such broker is registered here or it has
35764
+ * measured nothing. Unknown never vetoes a climb (D315/D393).
35765
+ */
35766
+ expectedTierKbps(deviceId, tier) {
35767
+ const prefix = `${String(deviceId)}/`;
35768
+ const candidates = [];
35769
+ for (const [bid, broker] of this.brokers) if (bid.startsWith(prefix)) candidates.push({
35770
+ brokerId: bid,
35771
+ tier: this.profileTierResolver?.(bid) ?? null,
35772
+ broker
35773
+ });
35774
+ const match = preferLiveAdaptiveCandidates(candidates).find((c) => c.tier === tier);
35775
+ if (match === void 0) return null;
35776
+ try {
35777
+ return expectedRungKbps(match.broker.getStats());
35778
+ } catch {
35779
+ return null;
35780
+ }
35781
+ }
35782
+ /**
34941
35783
  * Wire an {@link AdaptiveController} to a freshly-created adaptive session.
34942
35784
  * The controller is fed loss from two sources — RTCP Receiver Reports
34943
35785
  * (`onReceiverReport`) and, when injected, the device's client-reported
@@ -34954,12 +35796,20 @@ var BrokerWebrtcServer = class {
34954
35796
  * whichever produces the higher loss ratio across its window. The async
34955
35797
  * poll is guarded against overlapping ticks via `tickInFlight`.
34956
35798
  */
34957
- startAdaptive(entry, deviceId, initialTier, sessionId, priorFailures, priorUpgradeCount) {
35799
+ startAdaptive(entry, deviceId, initialTier, sessionId, intent) {
35800
+ const startedAt = Date.now();
35801
+ /** Rung bitrates, looked up at most once per tier per tick (review m12). */
35802
+ const rungKbps = /* @__PURE__ */ new Map();
34958
35803
  const controller = new AdaptiveController({
34959
35804
  initialTier,
34960
- priorFailures,
34961
- priorUpgradeCount,
35805
+ priorFailures: intent?.failures,
35806
+ priorUpgradeCount: intent?.upgradeCount,
35807
+ tryClimb: intent?.tryClimb ?? this.deviceTryMemory.get(deviceId, startedAt) ?? void 0,
34962
35808
  config: ADAPTIVE_CONFIG,
35809
+ expectedTierKbps: (tier) => {
35810
+ if (!rungKbps.has(tier)) rungKbps.set(tier, this.expectedTierKbps(deviceId, tier));
35811
+ return rungKbps.get(tier) ?? null;
35812
+ },
34963
35813
  onSwitchTier: (tier, decision) => {
34964
35814
  this.recordLadderDecision(sessionId, decision, tier, false);
34965
35815
  this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
@@ -34976,6 +35826,9 @@ var BrokerWebrtcServer = class {
34976
35826
  reason: decision.cause,
34977
35827
  lossRatio: Number(decision.lossRatio.toFixed(4)),
34978
35828
  clientKbps: decision.stall.clientKbps,
35829
+ sentKbps: decision.stall.sentKbps,
35830
+ deliveryRatio: decision.stall.deliveryRatio,
35831
+ deliveryWindowMs: decision.stall.deliveryWindowMs,
34979
35832
  stalledForMs: decision.stall.stalledForMs,
34980
35833
  serverSending: decision.stall.serverSending
34981
35834
  }
@@ -34995,14 +35848,39 @@ var BrokerWebrtcServer = class {
34995
35848
  * sending?" half of the stall verdict. */
34996
35849
  let lastRtpSent = entry.session.rtpPacketsSentTotal;
34997
35850
  const CLIENT_LOSS_SPAN = 1e3;
35851
+ const meter = new DeliveryMeter(ADAPTIVE_DELIVERY_CONFIG, deliveryMeterStart(startedAt, intent !== void 0, ADAPTIVE_CONFIG.waitAfterChangeMs));
35852
+ const holdLog = new HoldLogGate(HOLD_LOG_QUIET_MS);
35853
+ let lastTickFailureLogAt = Number.NEGATIVE_INFINITY;
35854
+ const logHoldChange = (now) => {
35855
+ const hold = controller.climbHold;
35856
+ if (hold === null || !holdLog.shouldLog(hold.code, now)) return;
35857
+ this.logger.info("adaptive: climb held", {
35858
+ tags: { deviceId },
35859
+ meta: {
35860
+ sessionId,
35861
+ deviceId,
35862
+ tier: controller.currentTier,
35863
+ nextTier: hold.nextTier,
35864
+ code: hold.code,
35865
+ summary: describeClimbHold(hold),
35866
+ deliveryRatio: hold.deliveryRatio,
35867
+ deliveryWindowMs: hold.deliveryWindowMs,
35868
+ capacityKbps: hold.capacityKbps,
35869
+ probeKbps: hold.probeKbps,
35870
+ nextTierKbps: hold.nextTierKbps
35871
+ }
35872
+ });
35873
+ };
34998
35874
  entry.adaptiveTimer = setInterval(() => {
34999
35875
  if (tickInFlight) return;
35000
35876
  tickInFlight = true;
35001
35877
  (async () => {
35002
35878
  try {
35879
+ rungKbps.clear();
35003
35880
  if (entry.timeline?.currentMode === "recorded") {
35004
35881
  controller.suspend(Date.now());
35005
35882
  lastRtpSent = entry.session.rtpPacketsSentTotal;
35883
+ logHoldChange(Date.now());
35006
35884
  return;
35007
35885
  }
35008
35886
  const client = this.clientNetworkStats ? await this.clientNetworkStats(deviceId) : null;
@@ -35010,11 +35888,15 @@ var BrokerWebrtcServer = class {
35010
35888
  const rtpSent = entry.session.rtpPacketsSentTotal;
35011
35889
  const serverSending = rtpSent > lastRtpSent;
35012
35890
  lastRtpSent = rtpSent;
35013
- controller.onThroughput({
35014
- clientKbps: client ? client.estimatedBandwidthKbps : null,
35015
- clientSampleAgeMs: client ? Math.max(0, now - client.lastUpdated) : null,
35016
- serverSending
35017
- }, now);
35891
+ this.feedAdaptive({
35892
+ entry,
35893
+ deviceId,
35894
+ controller,
35895
+ meter,
35896
+ client,
35897
+ serverSending,
35898
+ now
35899
+ });
35018
35900
  const pct = client?.packetLossPercent ?? null;
35019
35901
  if (pct !== null && pct !== void 0) {
35020
35902
  clientHighestSequence += CLIENT_LOSS_SPAN;
@@ -35025,6 +35907,21 @@ var BrokerWebrtcServer = class {
35025
35907
  }, now);
35026
35908
  }
35027
35909
  controller.tick(now);
35910
+ this.deviceTryMemory.set(deviceId, controller.tryClimbCarry, now);
35911
+ logHoldChange(now);
35912
+ } catch (err) {
35913
+ const now = Date.now();
35914
+ if (now - lastTickFailureLogAt >= TICK_FAILURE_LOG_QUIET_MS) {
35915
+ lastTickFailureLogAt = now;
35916
+ this.logger.warn("adaptive: tick failed", {
35917
+ tags: { deviceId },
35918
+ meta: {
35919
+ sessionId,
35920
+ deviceId,
35921
+ error: errMsg(err)
35922
+ }
35923
+ });
35924
+ }
35028
35925
  } finally {
35029
35926
  tickInFlight = false;
35030
35927
  }
@@ -35032,6 +35929,55 @@ var BrokerWebrtcServer = class {
35032
35929
  }, ADAPTIVE_TICK_MS);
35033
35930
  }
35034
35931
  /**
35932
+ * One evaluation's transport, capacity and RTT evidence into the controller.
35933
+ * The device-scoped client record is used as this session's own only when
35934
+ * this is the device's ONLY adaptive session here (review I2).
35935
+ */
35936
+ feedAdaptive(feed) {
35937
+ const { entry, deviceId, controller, meter, client, now } = feed;
35938
+ const sessionsOnDevice = this.adaptiveSessionCount(deviceId);
35939
+ const shared = sessionsOnDevice > 1;
35940
+ let rttMs = null;
35941
+ if (client) {
35942
+ const reportAgeMs = Math.max(0, now - client.lastUpdated);
35943
+ meter.observe(client.estimatedBandwidthKbps, client.lastUpdated);
35944
+ if (!shared) {
35945
+ this.recordDeviceProbe(deviceId, probeFromReport({
35946
+ downlinkMbps: client.downlinkMbps,
35947
+ downlinkAgeMs: client.downlinkAgeMs ?? 0,
35948
+ reportAgeMs
35949
+ }, now));
35950
+ if (reportAgeMs <= ADAPTIVE_DELIVERY_CONFIG.maxClientSampleAgeMs) rttMs = client.rttMs ?? null;
35951
+ }
35952
+ }
35953
+ const history = entry.session.sentVideoHistory;
35954
+ controller.onThroughput(buildStallSample({
35955
+ delivery: meter.evaluate(history, deliveryWindowMs(this.brokerIdrIntervalMs(entry.brokerId), ADAPTIVE_DELIVERY_CONFIG), now),
35956
+ recent: meter.evaluateNewest(history, now),
35957
+ clientReporting: meter.isReporting(now),
35958
+ serverSending: feed.serverSending,
35959
+ adaptiveSessionsOnDevice: sessionsOnDevice
35960
+ }), now);
35961
+ const probe = this.deviceProbe.get(deviceId, now);
35962
+ controller.onCapacity({
35963
+ rembKbps: entry.session.receiverEstimateKbps(now),
35964
+ probeKbps: probe?.kbps ?? null,
35965
+ probeFresh: isProbeFresh(probe, now)
35966
+ });
35967
+ controller.onRtt(rttMs, now);
35968
+ }
35969
+ /** Adaptive sessions of `deviceId` currently on this broker server. */
35970
+ adaptiveSessionCount(deviceId) {
35971
+ const prefix = `${String(deviceId)}/`;
35972
+ let count = 0;
35973
+ for (const s of this.sessions.values()) if (s.adaptiveController !== void 0 && s.brokerId.startsWith(prefix)) count += 1;
35974
+ return count;
35975
+ }
35976
+ /** Keep the newest probe per device; an absent one changes nothing. */
35977
+ recordDeviceProbe(deviceId, probe) {
35978
+ this.deviceProbe.record(deviceId, probe);
35979
+ }
35980
+ /**
35035
35981
  * Schedule an adaptive tier switch via a close-and-recreate re-offer.
35036
35982
  *
35037
35983
  * Records the controller's desired tier (+ forced transcode-down) in
@@ -35048,11 +35994,18 @@ var BrokerWebrtcServer = class {
35048
35994
  const prev = this.adaptiveIntent.get(sessionId);
35049
35995
  const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
35050
35996
  const upgradeCount = entry.adaptiveController?.successfulUpgradeCount ?? prev?.upgradeCount ?? 0;
35997
+ const tryClimb = entry.adaptiveController?.tryClimbCarry ?? prev?.tryClimb;
35051
35998
  this.adaptiveIntent.set(sessionId, {
35052
35999
  prefersTier: tier,
35053
36000
  forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
35054
36001
  failures,
35055
- upgradeCount
36002
+ upgradeCount,
36003
+ tryClimb: tryClimb ?? {
36004
+ failures: 0,
36005
+ notBefore: 0,
36006
+ lastRevert: null,
36007
+ trialFrom: null
36008
+ }
35056
36009
  });
35057
36010
  const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
35058
36011
  entry.session.pendingRenegotiation = {
@@ -35070,8 +36023,12 @@ var BrokerWebrtcServer = class {
35070
36023
  failures: Object.fromEntries(failures),
35071
36024
  upgradeCount,
35072
36025
  reason: decision?.cause ?? "unknown",
36026
+ trial: decision?.trial ?? false,
35073
36027
  lossRatio: decision ? Number(decision.lossRatio.toFixed(4)) : null,
35074
36028
  clientKbps: decision?.stall.clientKbps ?? null,
36029
+ sentKbps: decision?.stall.sentKbps ?? null,
36030
+ deliveryRatio: decision?.stall.deliveryRatio ?? null,
36031
+ deliveryWindowMs: decision?.stall.deliveryWindowMs ?? null,
35075
36032
  stalledForMs: decision?.stall.stalledForMs ?? null,
35076
36033
  serverSending: decision?.stall.serverSending ?? null
35077
36034
  }
@@ -35088,6 +36045,10 @@ var BrokerWebrtcServer = class {
35088
36045
  }
35089
36046
  if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
35090
36047
  this.adaptiveIntent.delete(sessionId);
36048
+ if (entry.adaptiveController !== void 0) {
36049
+ const deviceId = deviceIdFromBrokerId(entry.brokerId);
36050
+ if (this.adaptiveSessionCount(deviceId) === 0) this.deviceProbe.forget(deviceId);
36051
+ }
35091
36052
  this.pruneLadderDecisions();
35092
36053
  if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
35093
36054
  if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
@@ -36248,7 +37209,10 @@ var StreamBrokerAddon = class extends BaseAddon {
36248
37209
  return {
36249
37210
  packetLossPercent: client.packetLossPercent,
36250
37211
  estimatedBandwidthKbps: client.estimatedBandwidthKbps,
36251
- lastUpdated: client.lastUpdated
37212
+ lastUpdated: client.lastUpdated,
37213
+ rttMs: client.rttMs,
37214
+ ...client.downlinkMbps !== void 0 ? { downlinkMbps: client.downlinkMbps } : {},
37215
+ ...client.downlinkAgeMs !== void 0 ? { downlinkAgeMs: client.downlinkAgeMs } : {}
36252
37216
  };
36253
37217
  } catch {
36254
37218
  return null;