@camstack/addon-pipeline 1.2.288 → 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-DTvsx_iX.mjs → default-detection-model-DcTy-nJl.mjs} +1 -1
  4. package/dist/{default-detection-model-D5kMByfZ.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-mEtwupOP.mjs → dist-Bn6BQW8q.mjs} +1692 -920
  8. package/dist/{dist-CegiJ2Pw.js → dist-D7FonEUB.js} +1709 -919
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-B8p-rV9H.mjs → node-BN0Na3HN.mjs} +1 -1
  12. package/dist/{node-C8jhiPW2.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-C5bRH8-t.mjs → process-memory-CeZ8ms80.mjs} +1 -1
  16. package/dist/{process-memory-lPbJ1ta2.js → process-memory-DI4jrCtX.js} +1 -1
  17. package/dist/recorder/index.js +7432 -7264
  18. package/dist/recorder/index.mjs +7432 -7264
  19. package/dist/{segment-demux-js-BP5vqRPY.mjs → segment-demux-js-CNPzlcyO.mjs} +1 -1
  20. package/dist/{segment-demux-js-Dv1EPPdH.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-BqjkSRCu.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-C4EuFQ_w.mjs → hostInit-CbSGtrCj.mjs} +2 -2
  28. package/dist/stream-broker/index.js +1465 -133
  29. package/dist/stream-broker/index.mjs +1465 -133
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +5 -3
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqcT4fRG.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-DCbVs3IC.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-CegiJ2Pw.js");
7
- const require_node = require("../node-C8jhiPW2.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-Dv1EPPdH.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 = [
@@ -26564,6 +27373,22 @@ function gapReachable(gap, epochMs) {
26564
27373
  }
26565
27374
  //#endregion
26566
27375
  //#region src/stream-broker/recorded/control-protocol.ts
27376
+ /**
27377
+ * The rates a clip may be OFFERED at — re-exported from the capability, which
27378
+ * is where the one list lives (D612). The broker owns the clamp; the cap owns
27379
+ * the ladder; a second list here would be the second authority D62 forbids.
27380
+ *
27381
+ * Declared at the top of the file because the clamp and the `setRate` bound
27382
+ * are DERIVED from its ends (D621). They were three literals — this array, a
27383
+ * `Math.min(4, …)` and a `.max(4)` — and that is precisely how 8 and 16 came
27384
+ * to be offered while the clamp served 4 with no line anywhere saying so. One
27385
+ * decision now has one place to be written down.
27386
+ */
27387
+ var BROKER_PACED_RATES = require_dist.CLIP_BROKER_PACED_RATES;
27388
+ /** The slowest offered rate. Anything slower is clamped up to it. */
27389
+ var MIN_PACED_RATE = Math.min(...BROKER_PACED_RATES);
27390
+ /** The fastest offered rate. Anything faster is refused at the wire. */
27391
+ var MAX_PACED_RATE = Math.max(...BROKER_PACED_RATES);
26567
27392
  var ControlMessageSchema = require_dist.discriminatedUnion("t", [
26568
27393
  require_dist.object({
26569
27394
  t: require_dist.literal("playRecorded"),
@@ -26630,7 +27455,7 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
26630
27455
  }),
26631
27456
  require_dist.object({
26632
27457
  t: require_dist.literal("setRate"),
26633
- rate: require_dist.number().min(0).max(4)
27458
+ rate: require_dist.number().min(0).max(MAX_PACED_RATE)
26634
27459
  }),
26635
27460
  require_dist.object({
26636
27461
  t: require_dist.literal("stepFrame"),
@@ -26643,22 +27468,41 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
26643
27468
  })
26644
27469
  ]);
26645
27470
  /**
26646
- * Clamp a requested playback rate onto the supported domain: {0} ∪ [0.25, 4].
26647
- * 0 (and any non-finite / non-positive input) pauses; anything else is a
26648
- * continuous rate clamped into [0.25, 4]. Single server-side authority — the
26649
- * feeder applies it to every inbound `setRate`.
27471
+ * Clamp a requested playback rate onto the supported domain:
27472
+ * {0} ∪ [{@link MIN_PACED_RATE}, {@link MAX_PACED_RATE}] — the ends of
27473
+ * {@link BROKER_PACED_RATES} itself. 0 (and any non-finite / non-positive
27474
+ * input) pauses; anything else is a continuous rate clamped into that band.
27475
+ * Single server-side authority — the feeder applies it to every inbound
27476
+ * `setRate`.
27477
+ *
27478
+ * That every ladder entry survives this unchanged is now structural rather
27479
+ * than a discipline. What is NOT structural, and is therefore the gate that
27480
+ * matters, is whether the pacer really empties a clip `rate` times faster:
27481
+ * `clip-stream-feeder.spec` drives every entry and measures it.
26650
27482
  */
26651
27483
  function clampPlaybackRate(rate) {
26652
27484
  if (!Number.isFinite(rate) || rate <= 0) return 0;
26653
- return Math.min(4, Math.max(.25, rate));
27485
+ return Math.min(MAX_PACED_RATE, Math.max(MIN_PACED_RATE, rate));
26654
27486
  }
26655
27487
  /**
26656
- * The rates a clip may be OFFERED at — re-exported from the capability, which
26657
- * is where the one list lives (D612). The broker owns the clamp; the cap owns
26658
- * the ladder; a second list here would be the second authority D62 forbids.
26659
- * `control-protocol.spec` pins that every entry survives {@link clampPlaybackRate}.
27488
+ * Does a clip carry SOUND at this rate?
27489
+ *
27490
+ * ONE predicate, read by the branch that DROPS the audio
27491
+ * (`ClipStreamFeeder.pushNextAudio`) and by the branch that TELLS the viewer
27492
+ * why it is silent — because a second copy of `rate !== 1` is exactly the
27493
+ * disagreeing second switch D62 forbids, and the telling would then be able
27494
+ * to describe a drop that did not happen (or miss one that did).
27495
+ *
27496
+ * *"L'audio non serve in velocità non 1x!!!"* — the operator, 2026-09-23.
27497
+ * Resampling would need a decoder this path has not got, and pitch-shifted
27498
+ * sound is worse than none.
27499
+ *
27500
+ * Rate 0 is PAUSE, not a rate: nothing plays at all there, so it is not this
27501
+ * predicate's business and callers hold their last non-zero rate instead.
26660
27502
  */
26661
- var BROKER_PACED_RATES = require_dist.CLIP_BROKER_PACED_RATES;
27503
+ function audioPlaysAtRate(rate) {
27504
+ return rate === 1;
27505
+ }
26662
27506
  var ServerMessageSchema = require_dist.discriminatedUnion("t", [
26663
27507
  require_dist.object({
26664
27508
  t: require_dist.literal("state"),
@@ -26699,6 +27543,14 @@ var ServerMessageSchema = require_dist.discriminatedUnion("t", [
26699
27543
  clipId: require_dist.string().min(1),
26700
27544
  durationMs: require_dist.number().positive()
26701
27545
  }),
27546
+ require_dist.object({
27547
+ t: require_dist.literal("clipIncomplete"),
27548
+ clipId: require_dist.string().min(1),
27549
+ code: require_dist._enum(["stream-short", "stream-empty"]),
27550
+ /** The provider's own sentence, already Latin-1 safe. Shown where the
27551
+ * stream error is shown. */
27552
+ reason: require_dist.string().min(1)
27553
+ }),
26702
27554
  require_dist.object({
26703
27555
  t: require_dist.literal("clipAudio"),
26704
27556
  clipId: require_dist.string().min(1),
@@ -26711,8 +27563,23 @@ var ServerMessageSchema = require_dist.discriminatedUnion("t", [
26711
27563
  "no-audio-track",
26712
27564
  "unsupported-codec",
26713
27565
  "codec-mismatch",
26714
- "stream-failed"
26715
- ]).optional()
27566
+ "stream-failed",
27567
+ "rate"
27568
+ ]).optional(),
27569
+ /**
27570
+ * The rate the sound is suppressed AT. Present iff `reason` is `rate`,
27571
+ * and refused otherwise — see the check below.
27572
+ */
27573
+ rate: require_dist.number().positive().optional()
27574
+ }).check((ctx) => {
27575
+ const value = ctx.value;
27576
+ if (value.reason === "rate" === (value.rate !== void 0)) return;
27577
+ ctx.issues.push({
27578
+ code: "custom",
27579
+ message: "`rate` is present exactly when `reason` is `rate`",
27580
+ input: value,
27581
+ path: ["rate"]
27582
+ });
26716
27583
  })
26717
27584
  ]);
26718
27585
  function parseControlMessage(raw) {
@@ -27684,7 +28551,7 @@ var RecordedFeeder = class {
27684
28551
  /** Audio clock (PCMU 8 kHz / Opus 48 kHz); re-anchored together with video. */
27685
28552
  audioClock;
27686
28553
  clockHasAdvanced = false;
27687
- /** Playback rate: 0 (paused) or a continuous factor in [0.25, 4]. */
28554
+ /** Playback rate: 0 (paused) or a continuous factor in [0.25, 8]. */
27688
28555
  rate = 1;
27689
28556
  disposed = false;
27690
28557
  seekToken = 0;
@@ -28265,7 +29132,7 @@ var RecordedFeeder = class {
28265
29132
  }
28266
29133
  /**
28267
29134
  * Set playback rate. 0 pauses in place (the pacing loop aborts on its next
28268
- * guard check); any other value is clamped to [0.25, 4].
29135
+ * guard check); any other value is clamped to [0.25, 8].
28269
29136
  *
28270
29137
  * Rate-change strategy (documented choice): every transition TO a non-zero
28271
29138
  * rate — resume from pause AND a playing→playing rate change — re-enters via
@@ -30130,17 +30997,25 @@ var TimelineSession = class {
30130
30997
  this.lastPositionMs = null;
30131
30998
  const feeder = this.enterRecorded();
30132
30999
  await feeder.seek(offsetMs);
30133
- await Promise.all([this.reportClipDuration(clipId, feeder), this.reportClipAudio(clipId, feeder)]);
31000
+ await Promise.all([
31001
+ this.reportClipDuration(clipId, feeder),
31002
+ this.reportClipAudio(clipId, feeder),
31003
+ this.reportClipIncomplete(clipId, feeder)
31004
+ ]);
30134
31005
  }
30135
31006
  /**
30136
31007
  * Tell the viewer whether this clip has sound.
30137
31008
  *
30138
- * Silence has three causes the operator cannot tell apart by listening —
30139
- * a camera with no microphone, a transport that carried no audio, and a
30140
- * session that negotiated none — so the viewer is told which. Every branch
30141
- * that sends nothing says so (D391): a feeder that cannot answer, an answer
30142
- * that rejected, and an answer that landed after the pointer had left the
30143
- * clip, which must not be pinned on whatever came next.
31009
+ * Silence has causes the operator cannot tell apart by listening — a camera
31010
+ * with no microphone, a transport that carried no audio, a session that
31011
+ * negotiated none, and (D621) a clip playing at anything but 1×, where the
31012
+ * broker drops every frame on purpose — so the viewer is told which. Every
31013
+ * branch that sends nothing says so (D391): a feeder that cannot answer, an
31014
+ * answer that rejected, and an answer that landed after the pointer had left
31015
+ * the clip, which must not be pinned on whatever came next.
31016
+ *
31017
+ * The first answer SETTLES; the rate does not, so this also subscribes for
31018
+ * as long as the clip is on the pointer.
30144
31019
  */
30145
31020
  async reportClipAudio(clipId, feeder) {
30146
31021
  if (feeder.audio === void 0) {
@@ -30161,11 +31036,25 @@ var TimelineSession = class {
30161
31036
  });
30162
31037
  return;
30163
31038
  }
31039
+ this.sendClipAudio(clipId, report);
31040
+ feeder.watchAudio?.((later) => this.sendClipAudio(clipId, later));
31041
+ }
31042
+ /**
31043
+ * Send one `clipAudio`, or say why nothing was sent.
31044
+ *
31045
+ * ONE place, for the settle and for every later change: they are the same
31046
+ * statement about the same clip, and a second builder is how the two would
31047
+ * come to disagree about what `reason` means. The clip-identity guard lives
31048
+ * here too — a report that lands after the pointer has left names a clip
31049
+ * nobody is playing, and the viewer would mute whatever came next.
31050
+ */
31051
+ sendClipAudio(clipId, report) {
30164
31052
  if (this.clip?.clipId !== clipId) {
30165
31053
  this.deps.onLog?.("clip playback: audio dropped — the pointer has left this clip", {
30166
31054
  deviceId: this.deps.deviceId,
30167
31055
  clipId,
30168
- carried: report.carried
31056
+ carried: report.carried,
31057
+ reason: report.reason
30169
31058
  });
30170
31059
  return;
30171
31060
  }
@@ -30175,6 +31064,7 @@ var TimelineSession = class {
30175
31064
  carried: report.carried,
30176
31065
  codec: report.codec,
30177
31066
  reason: report.reason,
31067
+ rate: report.rate,
30178
31068
  detail: report.detail
30179
31069
  });
30180
31070
  this.deps.session.sendControl(serializeServerMessage({
@@ -30182,7 +31072,63 @@ var TimelineSession = class {
30182
31072
  clipId,
30183
31073
  carried: report.carried,
30184
31074
  ...report.codec !== null ? { codec: report.codec } : {},
30185
- ...report.reason !== null ? { reason: report.reason } : {}
31075
+ ...report.reason !== null ? { reason: report.reason } : {},
31076
+ ...report.reason === "rate" && report.rate !== void 0 ? { rate: report.rate } : {}
31077
+ }));
31078
+ }
31079
+ /**
31080
+ * Tell the operator that the clip he is watching did not arrive whole
31081
+ * (D623), in the surface that already shows him a stream error.
31082
+ *
31083
+ * Awaited AFTER the stream ends, which is when the provider gets to speak,
31084
+ * and guarded by the clip identity for the same reason the audio report is:
31085
+ * a late answer names a clip nobody is playing and the viewer would put the
31086
+ * complaint on whatever came next.
31087
+ *
31088
+ * There is no retry here and there must not be one. *"Se cade la sessione
31089
+ * cade non fa niente"* — the operator, 2026-09-23. A second dial would
31090
+ * contend for the camera's single replay lane exactly while that lane is
31091
+ * already failing.
31092
+ */
31093
+ async reportClipIncomplete(clipId, feeder) {
31094
+ if (feeder.incomplete === void 0) {
31095
+ this.deps.onLog?.("clip playback: completeness not reported — this feeder cannot ask the provider", {
31096
+ deviceId: this.deps.deviceId,
31097
+ clipId
31098
+ });
31099
+ return;
31100
+ }
31101
+ let report;
31102
+ try {
31103
+ report = await feeder.incomplete();
31104
+ } catch (err) {
31105
+ this.deps.onLog?.("clip playback: completeness not reported — the answer failed", {
31106
+ deviceId: this.deps.deviceId,
31107
+ clipId,
31108
+ error: err instanceof Error ? err.message : String(err)
31109
+ });
31110
+ return;
31111
+ }
31112
+ if (report === null) return;
31113
+ if (this.clip?.clipId !== clipId) {
31114
+ this.deps.onLog?.("clip playback: completeness dropped — the pointer has left this clip", {
31115
+ deviceId: this.deps.deviceId,
31116
+ clipId,
31117
+ code: report.code
31118
+ });
31119
+ return;
31120
+ }
31121
+ this.deps.onLog?.("clip playback: the clip did not arrive whole", {
31122
+ deviceId: this.deps.deviceId,
31123
+ clipId,
31124
+ code: report.code,
31125
+ reason: report.reason
31126
+ });
31127
+ this.deps.session.sendControl(serializeServerMessage({
31128
+ t: "clipIncomplete",
31129
+ clipId,
31130
+ code: report.code,
31131
+ reason: report.reason
30186
31132
  }));
30187
31133
  }
30188
31134
  /**
@@ -31584,7 +32530,7 @@ function createFmp4StreamDemuxer(options = {}) {
31584
32530
  * faster than the camera sends.
31585
32531
  * - `stepFrame(-1)` and the scrub verbs are refused by name; `stepFrame(+1)`
31586
32532
  * pushes the next buffered unit.
31587
- * - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25, 4],
32533
+ * - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25, 8],
31588
32534
  * the recorded domain). The camera's own `playSpeed` (8, 16 — real, the app
31589
32535
  * reopens the replay at 8) is a different mechanism and is NOT wired: it
31590
32536
  * would go on the dial (`videoclips.dialClipStream` → the cmd-5 replay's
@@ -31650,6 +32596,11 @@ function createFmp4StreamDemuxer(options = {}) {
31650
32596
  * `data`, which the broker's `onLog` puts in `tags`.
31651
32597
  */
31652
32598
  /**
32599
+ * The reason an in-flight open is abandoned, carried on the abort so the
32600
+ * opener's refusal and the provider's log line say the same thing.
32601
+ */
32602
+ var CLIP_STREAM_ABANDONED = "clip-stream-abandoned";
32603
+ /**
31653
32604
  * The same ceiling as the file path (`CLIP_SOURCE_MAX_BYTES`): a clip the
31654
32605
  * file path would refuse `too-large` is one this path stops taking. Frames
31655
32606
  * behind the head are released as they are pushed, so the backlog is the
@@ -31672,6 +32623,8 @@ var ClipStreamFeeder = class {
31672
32623
  deps;
31673
32624
  phase = "idle";
31674
32625
  connection = null;
32626
+ /** Armed for the length of one `open()`; aborted by {@link teardown}. */
32627
+ opening = null;
31675
32628
  demuxer;
31676
32629
  clock = new MonotonicClock();
31677
32630
  /** The RTP rate the audio sender negotiated — a CONVERSION, not a clock. */
@@ -31684,10 +32637,28 @@ var ClipStreamFeeder = class {
31684
32637
  audioPushed = 0;
31685
32638
  audioDroppedLate = 0;
31686
32639
  audioDroppedRate = 0;
32640
+ /**
32641
+ * The last report handed to a caller — the settle promise's value, or the
32642
+ * newest one {@link watchAudio}'s listener was given. What a change is
32643
+ * measured against, so the same answer is never announced twice.
32644
+ */
32645
+ audioEmitted = null;
32646
+ /**
32647
+ * The last NON-ZERO rate. Pause is the absence of a rate, not a rate: a
32648
+ * `clipAudio` blaming the speed while the clip is stopped would be false,
32649
+ * and un-pausing would have to take it back.
32650
+ */
32651
+ audioRate = 1;
32652
+ audioListener = null;
31687
32653
  audioDroppedSeek = 0;
31688
32654
  firstAudioLogged = false;
31689
32655
  audioReport = null;
31690
32656
  audioWaiters = [];
32657
+ /** The provider's last word, and whether it has been given. Settled is not
32658
+ * the same as non-null: a whole stream settles `null` (D623). */
32659
+ incompleteSettled = false;
32660
+ incompleteReport = null;
32661
+ incompleteWaiters = [];
31691
32662
  clockHasAdvanced = false;
31692
32663
  rate = 1;
31693
32664
  queue = [];
@@ -31719,12 +32690,16 @@ var ClipStreamFeeder = class {
31719
32690
  firstFrameLogged = false;
31720
32691
  maxBufferedBytes;
31721
32692
  stallTimeoutMs;
32693
+ prerollMs;
32694
+ /** True while the pacer is waiting for the cushion to refill after a starve. */
32695
+ rebuffering = false;
31722
32696
  now;
31723
32697
  tags;
31724
32698
  constructor(deps) {
31725
32699
  this.deps = deps;
31726
32700
  this.maxBufferedBytes = deps.maxBufferedBytes ?? CLIP_STREAM_BUFFER_MAX_BYTES;
31727
32701
  this.stallTimeoutMs = deps.stallTimeoutMs ?? 15e3;
32702
+ this.prerollMs = deps.prerollMs ?? 600;
31728
32703
  this.now = deps.now ?? (() => Date.now());
31729
32704
  this.tags = {
31730
32705
  deviceId: deps.handle.deviceId,
@@ -31822,6 +32797,8 @@ var ClipStreamFeeder = class {
31822
32797
  this.clock.setRate(next);
31823
32798
  const wasPaused = this.rate === 0;
31824
32799
  this.rate = next;
32800
+ this.audioRate = next;
32801
+ this.refreshAudioReport();
31825
32802
  if (this.phase !== "streaming" || this.pendingSeekMs !== null) return;
31826
32803
  if (wasPaused) this.emitState("playing", "set-rate-resume", this.seekToken);
31827
32804
  this.startPacing();
@@ -31856,6 +32833,19 @@ var ClipStreamFeeder = class {
31856
32833
  return new Promise((resolve) => this.durationWaiters.push(resolve));
31857
32834
  }
31858
32835
  /**
32836
+ * Did the provider deliver this clip whole? (D623)
32837
+ *
32838
+ * Resolves `null` for a whole stream AND for one that never got to say —
32839
+ * cut, disposed, or opened against a provider that adds no trailer. Both are
32840
+ * "nothing to tell the operator", and neither may be reported as a defect:
32841
+ * a cut already announces itself as `state: error`, and inventing a second
32842
+ * complaint for it would file one fault twice.
32843
+ */
32844
+ incomplete() {
32845
+ if (this.incompleteSettled) return Promise.resolve(this.incompleteReport);
32846
+ return new Promise((resolve) => this.incompleteWaiters.push(resolve));
32847
+ }
32848
+ /**
31859
32849
  * What this clip turned out to carry as sound — the answer the viewer needs
31860
32850
  * to say "this clip has no audio" rather than leave the operator wondering.
31861
32851
  *
@@ -31865,15 +32855,31 @@ var ClipStreamFeeder = class {
31865
32855
  */
31866
32856
  audio() {
31867
32857
  const settled = this.audioReport;
31868
- if (settled !== null) return Promise.resolve(settled);
32858
+ if (settled !== null) return Promise.resolve(this.audioNow(settled));
31869
32859
  return new Promise((resolve) => this.audioWaiters.push(resolve));
31870
32860
  }
32861
+ /**
32862
+ * Follow the audio answer for as long as this clip is on the pointer.
32863
+ *
32864
+ * {@link audio} settles ONCE, because what the stream carries is decided
32865
+ * once — at the `moov`. The RATE is not: it moves under the operator's
32866
+ * finger, and a one-shot report cannot say that the clip which had sound a
32867
+ * second ago is silent now because he pressed 2×. The listener is told only
32868
+ * when the answer really changes, never the settle it already awaited.
32869
+ *
32870
+ * One listener, replaced rather than added to: there is one session behind
32871
+ * one clip, and a list would outlive the pointer.
32872
+ */
32873
+ watchAudio(listener) {
32874
+ this.audioListener = listener;
32875
+ }
31871
32876
  dispose() {
31872
32877
  if (this.phase === "disposed") return;
31873
32878
  const before = this.phase;
31874
32879
  this.phase = "disposed";
31875
32880
  this.teardown();
31876
32881
  this.settleDuration(null);
32882
+ this.settleIncomplete(null);
31877
32883
  this.settleAudio({
31878
32884
  carried: false,
31879
32885
  codec: null,
@@ -31944,13 +32950,15 @@ var ClipStreamFeeder = class {
31944
32950
  audioReason: answer.audioReason ?? null
31945
32951
  });
31946
32952
  }
32953
+ const abandon = new AbortController();
32954
+ this.opening = abandon;
31947
32955
  let opened;
31948
32956
  try {
31949
32957
  opened = await this.deps.open(answer.url, {
31950
32958
  onData: (chunk) => this.onData(chunk),
31951
- onEnd: () => this.onEnd(),
32959
+ onEnd: (report) => this.onEnd(report),
31952
32960
  onError: (err) => this.cut("stream-failed", err.message)
31953
- });
32961
+ }, abandon.signal);
31954
32962
  } catch (err) {
31955
32963
  opened = {
31956
32964
  kind: "refused",
@@ -31959,8 +32967,15 @@ var ClipStreamFeeder = class {
31959
32967
  detail: describe(err)
31960
32968
  };
31961
32969
  }
32970
+ if (this.opening === abandon) this.opening = null;
31962
32971
  if (this.phase !== "opening") {
31963
32972
  if (opened.kind === "open") opened.connection.destroy();
32973
+ this.log("clip-stream:open-abandoned", {
32974
+ phase: this.phase,
32975
+ opened: opened.kind,
32976
+ sinceDialMs: this.now() - (this.dialedAt ?? this.now()),
32977
+ ...opened.kind === "refused" ? { code: opened.code } : {}
32978
+ });
31964
32979
  return;
31965
32980
  }
31966
32981
  if (opened.kind === "refused") {
@@ -31980,6 +32995,7 @@ var ClipStreamFeeder = class {
31980
32995
  this.phase = "refused";
31981
32996
  this.teardown();
31982
32997
  this.settleDuration(null);
32998
+ this.settleIncomplete(null);
31983
32999
  this.settleAudio({
31984
33000
  carried: false,
31985
33001
  codec: null,
@@ -32101,10 +33117,24 @@ var ClipStreamFeeder = class {
32101
33117
  return;
32102
33118
  }
32103
33119
  while (this.rate === 0 && this.pendingSteps > 0 && this.pushNext()) this.pendingSteps -= 1;
32104
- if (this.rate > 0 && this.paceTimer === null) this.tick();
33120
+ if (this.rate === 0) return;
33121
+ if (this.rebuffering) {
33122
+ if (this.cushionShortfallMs() > 0) return;
33123
+ this.rebuffering = false;
33124
+ this.log("clip-stream:refilled", {
33125
+ headMs: this.headMs,
33126
+ arrivedMs: this.arrivedMs,
33127
+ queued: this.queue.length,
33128
+ starved: this.starved
33129
+ });
33130
+ this.startPacing();
33131
+ return;
33132
+ }
33133
+ if (this.paceTimer === null) this.tick();
32105
33134
  }
32106
- onEnd() {
33135
+ onEnd(report) {
32107
33136
  if (!this.takesBytes()) return;
33137
+ this.settleIncomplete(report);
32108
33138
  this.streamEnded = true;
32109
33139
  this.clearStall();
32110
33140
  this.streamEnd = this.demuxer.end();
@@ -32122,13 +33152,20 @@ var ClipStreamFeeder = class {
32122
33152
  this.resolvePendingSeek(this.seekToken);
32123
33153
  return;
32124
33154
  }
32125
- if (this.rate > 0 && this.paceTimer === null) this.tick();
33155
+ if (this.rate === 0) return;
33156
+ if (this.rebuffering || this.anchorWall > this.now()) {
33157
+ this.rebuffering = false;
33158
+ this.startPacing();
33159
+ return;
33160
+ }
33161
+ if (this.paceTimer === null) this.tick();
32126
33162
  }
32127
33163
  cut(reason, detail) {
32128
33164
  if (this.phase !== "streaming" && this.phase !== "opening") return;
32129
33165
  this.phase = "cut";
32130
33166
  this.teardown();
32131
33167
  this.settleDuration(null);
33168
+ this.settleIncomplete(null);
32132
33169
  this.settleAudio({
32133
33170
  carried: false,
32134
33171
  codec: null,
@@ -32262,9 +33299,24 @@ var ClipStreamFeeder = class {
32262
33299
  this.audioQueue = this.audioQueue.slice(drop);
32263
33300
  this.audioDroppedSeek += drop;
32264
33301
  }
33302
+ /**
33303
+ * How much of the cushion is MISSING, in milliseconds of media.
33304
+ *
33305
+ * Zero once the stream has ended — there is nothing more coming, so waiting
33306
+ * for it would hang the clip — and zero for any source that already holds
33307
+ * more than the cushion, which is every fast one.
33308
+ */
33309
+ cushionShortfallMs() {
33310
+ if (this.streamEnded) return 0;
33311
+ const head = this.headMs;
33312
+ const arrived = this.arrivedMs;
33313
+ if (head === null || arrived === null) return this.prerollMs;
33314
+ return Math.max(0, this.prerollMs - (arrived - head));
33315
+ }
32265
33316
  startPacing() {
32266
33317
  this.stopPacing();
32267
- this.anchorWall = this.now();
33318
+ const shortfall = this.cushionShortfallMs();
33319
+ this.anchorWall = this.now() + shortfall;
32268
33320
  this.anchorPts = this.headMs ?? this.queue[0]?.ptsMs ?? 0;
32269
33321
  this.startPositionReports();
32270
33322
  this.tick();
@@ -32289,7 +33341,10 @@ var ClipStreamFeeder = class {
32289
33341
  const audio = this.audioQueue[0];
32290
33342
  if (video === void 0 && audio === void 0) {
32291
33343
  if (this.streamEnded) this.finish();
32292
- else this.noteStarved();
33344
+ else {
33345
+ this.rebuffering = true;
33346
+ this.noteStarved();
33347
+ }
32293
33348
  return;
32294
33349
  }
32295
33350
  const takeVideo = video !== void 0 && (audio === void 0 || video.ptsMs <= audio.ptsMs);
@@ -32325,7 +33380,7 @@ var ClipStreamFeeder = class {
32325
33380
  if (frame === void 0) return false;
32326
33381
  this.audioQueue = this.audioQueue.slice(1);
32327
33382
  this.audioQueuedBytes -= frame.data.byteLength;
32328
- if (this.rate !== 1) {
33383
+ if (!audioPlaysAtRate(this.rate)) {
32329
33384
  this.audioDroppedRate += 1;
32330
33385
  return true;
32331
33386
  }
@@ -32423,11 +33478,53 @@ var ClipStreamFeeder = class {
32423
33478
  audioQueued: this.audioQueue.length
32424
33479
  };
32425
33480
  }
33481
+ /** One-shot, like the duration beside it: the provider speaks once, at the
33482
+ * end of the body, and a cut settles `null` so nobody waits for ever. */
33483
+ settleIncomplete(report) {
33484
+ if (this.incompleteSettled) return;
33485
+ this.incompleteSettled = true;
33486
+ this.incompleteReport = report;
33487
+ if (report !== null) this.log("clip-stream:incomplete", {
33488
+ code: report.code,
33489
+ reason: report.reason
33490
+ });
33491
+ for (const resolve of this.incompleteWaiters.splice(0)) resolve(report);
33492
+ }
32426
33493
  settleAudio(report) {
32427
33494
  if (this.audioReport !== null) return;
32428
33495
  this.audioReport = report;
33496
+ const now = this.audioNow(report);
33497
+ this.audioEmitted = now;
32429
33498
  const waiters = this.audioWaiters.splice(0);
32430
- for (const resolve of waiters) resolve(report);
33499
+ for (const resolve of waiters) resolve(now);
33500
+ }
33501
+ /**
33502
+ * The audio answer AS IT STANDS — the stream's own settled fact, with the
33503
+ * rate laid over it.
33504
+ *
33505
+ * The overlay only ever applies to a clip that really is carrying sound. A
33506
+ * clip whose camera has no microphone is silent at 1× too, and replacing
33507
+ * `no-audio-track` with `rate` would swap the true cause for a transient
33508
+ * one and send the operator to the speed chip.
33509
+ */
33510
+ audioNow(settled) {
33511
+ if (!settled.carried || audioPlaysAtRate(this.audioRate)) return settled;
33512
+ return {
33513
+ carried: false,
33514
+ codec: null,
33515
+ reason: "rate",
33516
+ rate: this.audioRate
33517
+ };
33518
+ }
33519
+ /** Tell the listener when — and only when — the answer has actually moved. */
33520
+ refreshAudioReport() {
33521
+ const settled = this.audioReport;
33522
+ if (settled === null) return;
33523
+ const next = this.audioNow(settled);
33524
+ const last = this.audioEmitted;
33525
+ if (last !== null && last.reason === next.reason && last.rate === next.rate) return;
33526
+ this.audioEmitted = next;
33527
+ this.audioListener?.(next);
32431
33528
  }
32432
33529
  settleDuration(value) {
32433
33530
  if (this.duration !== null) return;
@@ -32442,6 +33539,9 @@ var ClipStreamFeeder = class {
32442
33539
  this.stopPacing();
32443
33540
  this.stopPositionReports();
32444
33541
  this.clearStall();
33542
+ const opening = this.opening;
33543
+ this.opening = null;
33544
+ opening?.abort(/* @__PURE__ */ new Error(CLIP_STREAM_ABANDONED));
32445
33545
  const connection = this.connection;
32446
33546
  this.connection = null;
32447
33547
  connection?.destroy();
@@ -32483,6 +33583,62 @@ var ClipStreamFeeder = class {
32483
33583
  function describe(err) {
32484
33584
  return err instanceof Error ? err.message : String(err);
32485
33585
  }
33586
+ //#endregion
33587
+ //#region src/stream-broker/recorded/clip-stream-http.ts
33588
+ /**
33589
+ * clip-stream-http — the `node:http` half of {@link ClipStreamOpener}.
33590
+ *
33591
+ * Dials the URL `videoclips.dialClipStream` handed back — plain HTTP on the
33592
+ * provider's own token-gated listener, never the hub's proxy (an addon holds
33593
+ * no user credential, and the hub proxy takes only those) — and turns the
33594
+ * response into what the feeder drives: `pause`/`resume`/`destroy` on the
33595
+ * socket and the three events.
33596
+ *
33597
+ * A status other than 200 is a REFUSAL, named by the provider's own headers
33598
+ * (`x-camstack-reason-code`, `x-camstack-reason`), never a stream of nothing.
33599
+ * A 200 that does not declare `x-camstack-clip-transport: forward-only` is
33600
+ * refused too: it means the URL is not the stream route, and playing a
33601
+ * progressive file through a forward-only feeder would look like it worked
33602
+ * until the first seek.
33603
+ */
33604
+ /**
33605
+ * The codes a provider may add AFTER its body, and the only two this hop
33606
+ * carries onward.
33607
+ *
33608
+ * A stream's status line is spent on its first fragment, so a serve the camera
33609
+ * cut short has nothing left to say it with but a trailer — and a trailer this
33610
+ * reader did not know about would be read as a whole clip, which is the defect
33611
+ * D623 is about, one hop further out. Anything else a provider writes there is
33612
+ * ITS diagnostic and stays in ITS log: a viewer is shown what it can act on.
33613
+ */
33614
+ var CARRIED_TRAILER_CODES = ["stream-short", "stream-empty"];
33615
+ function trailerText(trailers, name) {
33616
+ const value = trailers[name];
33617
+ return typeof value === "string" && value.length > 0 ? value : void 0;
33618
+ }
33619
+ /**
33620
+ * What the provider said about its own stream once the last byte was out.
33621
+ *
33622
+ * `null` for a stream that added nothing, which is a whole one — the absence
33623
+ * is the statement, and it is never read as "unknown". A code with no sentence
33624
+ * is still reported, with the code standing in for one: a consumer that
33625
+ * branches on the code must not lose the branch because the prose was dropped
33626
+ * by a proxy.
33627
+ */
33628
+ function readClipStreamTrailers(trailers) {
33629
+ const code = trailerText(trailers, require_dist.CLIP_STILL_REASON_CODE_HEADER);
33630
+ if (code === void 0 || !CARRIED_TRAILER_CODES.includes(code)) return null;
33631
+ return {
33632
+ code: code === "stream-empty" ? "stream-empty" : "stream-short",
33633
+ reason: trailerText(trailers, "x-camstack-reason") ?? code
33634
+ };
33635
+ }
33636
+ /** The abort's reason, as a sentence the provider's log can be matched against. */
33637
+ function describeReason(reason) {
33638
+ if (reason instanceof Error) return reason.message;
33639
+ if (typeof reason === "string") return reason;
33640
+ return "the clip stream was abandoned before it opened";
33641
+ }
32486
33642
  function headerText(res, name) {
32487
33643
  const value = res.headers[name];
32488
33644
  if (Array.isArray(value)) return value[0];
@@ -32490,14 +33646,26 @@ function headerText(res, name) {
32490
33646
  }
32491
33647
  function createHttpClipStreamOpener(options = {}) {
32492
33648
  const headersTimeoutMs = options.headersTimeoutMs ?? 3e4;
32493
- return (url, handlers) => new Promise((resolve) => {
33649
+ return (url, handlers, signal) => new Promise((resolve) => {
32494
33650
  let settled = false;
33651
+ let onAbort = null;
32495
33652
  const settle = (value) => {
32496
33653
  if (settled) return;
32497
33654
  settled = true;
32498
33655
  clearTimeout(timer);
33656
+ if (onAbort !== null) signal.removeEventListener("abort", onAbort);
32499
33657
  resolve(value);
32500
33658
  };
33659
+ const abandoned = () => ({
33660
+ kind: "refused",
33661
+ status: 0,
33662
+ code: "abandoned",
33663
+ detail: describeReason(signal.reason)
33664
+ });
33665
+ if (signal.aborted) {
33666
+ resolve(abandoned());
33667
+ return;
33668
+ }
32501
33669
  let target;
32502
33670
  try {
32503
33671
  target = new URL(url);
@@ -32526,7 +33694,16 @@ function createHttpClipStreamOpener(options = {}) {
32526
33694
  const timer = setTimeout(() => {
32527
33695
  req.destroy(/* @__PURE__ */ new Error(`no status line in ${headersTimeoutMs} ms`));
32528
33696
  }, headersTimeoutMs);
33697
+ onAbort = () => {
33698
+ req.destroy(new Error(describeReason(signal.reason)));
33699
+ settle(abandoned());
33700
+ };
33701
+ signal.addEventListener("abort", onAbort, { once: true });
32529
33702
  req.on("error", (err) => {
33703
+ if (signal.aborted) {
33704
+ settle(abandoned());
33705
+ return;
33706
+ }
32530
33707
  settle({
32531
33708
  kind: "refused",
32532
33709
  status: 0,
@@ -32557,7 +33734,7 @@ function createHttpClipStreamOpener(options = {}) {
32557
33734
  return;
32558
33735
  }
32559
33736
  res.on("data", (chunk) => handlers.onData(chunk));
32560
- res.on("end", () => handlers.onEnd());
33737
+ res.on("end", () => handlers.onEnd(readClipStreamTrailers(res.trailers)));
32561
33738
  res.on("error", (err) => handlers.onError(err));
32562
33739
  settle({
32563
33740
  kind: "open",
@@ -33147,24 +34324,13 @@ function pickAdaptiveTier(input) {
33147
34324
  *
33148
34325
  * Flow: Camera → RTSP → Broker → EncodedPacket → RTP packetization → WebRTC
33149
34326
  */
33150
- /** How often the adaptive controller re-evaluates the ladder (ms). */
33151
- var ADAPTIVE_TICK_MS = 2e3;
33152
- /** Ladder tuning shared by every adaptive session. */
33153
- var ADAPTIVE_CONFIG = {
33154
- analyzeWindowMs: 15e3,
33155
- minFailPercent: .05,
33156
- waitAfterResetMs: 8e3,
33157
- upgradeHealthyMs: 4e3,
33158
- upgradeHealthyGrowthFactor: 3,
33159
- maxProgressiveUpgradeHealthyMs: 3e4,
33160
- minTimeBetweenChangesMs: 4e3,
33161
- maxUpgradeBackoffMs: 9e5,
33162
- stallKbpsFloor: 64,
33163
- stallConfirmMs: 6e3,
33164
- recoverKbpsFloor: 128,
33165
- waitAfterChangeMs: 8e3,
33166
- maxClientSampleAgeMs: 1e4
33167
- };
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;
33168
34334
  /**
33169
34335
  * True when `broker` is an in-process StreamBroker exposing the Opus egress
33170
34336
  * controls. Uses `in` narrowing + `typeof` checks (no cast) so the returned
@@ -33401,6 +34567,14 @@ var BrokerWebrtcServer = class {
33401
34567
  */
33402
34568
  lastLadderDecision = /* @__PURE__ */ new Map();
33403
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
+ /**
33404
34578
  * brokerId → where its media is ingested. Injected by the manager, which
33405
34579
  * owns the ownership map; unset (or a null answer) means UNRESOLVED, and
33406
34580
  * the overlay says so rather than naming the local node.
@@ -34398,7 +35572,8 @@ var BrokerWebrtcServer = class {
34398
35572
  };
34399
35573
  if (streamId.endsWith("/adaptive")) {
34400
35574
  const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
34401
- 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);
34402
35577
  }
34403
35578
  return {
34404
35579
  sessionId,
@@ -34516,11 +35691,13 @@ var BrokerWebrtcServer = class {
34516
35691
  const ladder = controller !== void 0 ? {
34517
35692
  lastDecision: decision,
34518
35693
  failures: [...controller.failureCounts],
34519
- upgradeCount: controller.successfulUpgradeCount
35694
+ upgradeCount: controller.successfulUpgradeCount,
35695
+ climbHold: controller.climbHold === null ? null : toWireClimbHold(controller.climbHold)
34520
35696
  } : decision !== null ? {
34521
35697
  lastDecision: decision,
34522
35698
  failures: [],
34523
- upgradeCount: 0
35699
+ upgradeCount: 0,
35700
+ climbHold: null
34524
35701
  } : null;
34525
35702
  let brokerStats = null;
34526
35703
  try {
@@ -34563,7 +35740,7 @@ var BrokerWebrtcServer = class {
34563
35740
  /** Snapshot the ladder's move so the overlay can name it later. */
34564
35741
  recordLadderDecision(sessionId, decision, tier, forcedTranscodeDown) {
34565
35742
  this.lastLadderDecision.set(sessionId, {
34566
- cause: decision.cause,
35743
+ ...wireLadderCause(decision, ADAPTIVE_CONFIG.stallConfirmMs),
34567
35744
  tier,
34568
35745
  forcedTranscodeDown,
34569
35746
  lossRatio: decision.lossRatio,
@@ -34574,6 +35751,39 @@ var BrokerWebrtcServer = class {
34574
35751
  });
34575
35752
  }
34576
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
+ /**
34577
35787
  * Wire an {@link AdaptiveController} to a freshly-created adaptive session.
34578
35788
  * The controller is fed loss from two sources — RTCP Receiver Reports
34579
35789
  * (`onReceiverReport`) and, when injected, the device's client-reported
@@ -34590,12 +35800,20 @@ var BrokerWebrtcServer = class {
34590
35800
  * whichever produces the higher loss ratio across its window. The async
34591
35801
  * poll is guarded against overlapping ticks via `tickInFlight`.
34592
35802
  */
34593
- 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();
34594
35807
  const controller = new AdaptiveController({
34595
35808
  initialTier,
34596
- priorFailures,
34597
- priorUpgradeCount,
35809
+ priorFailures: intent?.failures,
35810
+ priorUpgradeCount: intent?.upgradeCount,
35811
+ tryClimb: intent?.tryClimb ?? this.deviceTryMemory.get(deviceId, startedAt) ?? void 0,
34598
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
+ },
34599
35817
  onSwitchTier: (tier, decision) => {
34600
35818
  this.recordLadderDecision(sessionId, decision, tier, false);
34601
35819
  this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
@@ -34612,6 +35830,9 @@ var BrokerWebrtcServer = class {
34612
35830
  reason: decision.cause,
34613
35831
  lossRatio: Number(decision.lossRatio.toFixed(4)),
34614
35832
  clientKbps: decision.stall.clientKbps,
35833
+ sentKbps: decision.stall.sentKbps,
35834
+ deliveryRatio: decision.stall.deliveryRatio,
35835
+ deliveryWindowMs: decision.stall.deliveryWindowMs,
34615
35836
  stalledForMs: decision.stall.stalledForMs,
34616
35837
  serverSending: decision.stall.serverSending
34617
35838
  }
@@ -34631,14 +35852,39 @@ var BrokerWebrtcServer = class {
34631
35852
  * sending?" half of the stall verdict. */
34632
35853
  let lastRtpSent = entry.session.rtpPacketsSentTotal;
34633
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
+ };
34634
35878
  entry.adaptiveTimer = setInterval(() => {
34635
35879
  if (tickInFlight) return;
34636
35880
  tickInFlight = true;
34637
35881
  (async () => {
34638
35882
  try {
35883
+ rungKbps.clear();
34639
35884
  if (entry.timeline?.currentMode === "recorded") {
34640
35885
  controller.suspend(Date.now());
34641
35886
  lastRtpSent = entry.session.rtpPacketsSentTotal;
35887
+ logHoldChange(Date.now());
34642
35888
  return;
34643
35889
  }
34644
35890
  const client = this.clientNetworkStats ? await this.clientNetworkStats(deviceId) : null;
@@ -34646,11 +35892,15 @@ var BrokerWebrtcServer = class {
34646
35892
  const rtpSent = entry.session.rtpPacketsSentTotal;
34647
35893
  const serverSending = rtpSent > lastRtpSent;
34648
35894
  lastRtpSent = rtpSent;
34649
- controller.onThroughput({
34650
- clientKbps: client ? client.estimatedBandwidthKbps : null,
34651
- clientSampleAgeMs: client ? Math.max(0, now - client.lastUpdated) : null,
34652
- serverSending
34653
- }, now);
35895
+ this.feedAdaptive({
35896
+ entry,
35897
+ deviceId,
35898
+ controller,
35899
+ meter,
35900
+ client,
35901
+ serverSending,
35902
+ now
35903
+ });
34654
35904
  const pct = client?.packetLossPercent ?? null;
34655
35905
  if (pct !== null && pct !== void 0) {
34656
35906
  clientHighestSequence += CLIENT_LOSS_SPAN;
@@ -34661,6 +35911,21 @@ var BrokerWebrtcServer = class {
34661
35911
  }, now);
34662
35912
  }
34663
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
+ }
34664
35929
  } finally {
34665
35930
  tickInFlight = false;
34666
35931
  }
@@ -34668,6 +35933,55 @@ var BrokerWebrtcServer = class {
34668
35933
  }, ADAPTIVE_TICK_MS);
34669
35934
  }
34670
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
+ /**
34671
35985
  * Schedule an adaptive tier switch via a close-and-recreate re-offer.
34672
35986
  *
34673
35987
  * Records the controller's desired tier (+ forced transcode-down) in
@@ -34684,11 +35998,18 @@ var BrokerWebrtcServer = class {
34684
35998
  const prev = this.adaptiveIntent.get(sessionId);
34685
35999
  const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
34686
36000
  const upgradeCount = entry.adaptiveController?.successfulUpgradeCount ?? prev?.upgradeCount ?? 0;
36001
+ const tryClimb = entry.adaptiveController?.tryClimbCarry ?? prev?.tryClimb;
34687
36002
  this.adaptiveIntent.set(sessionId, {
34688
36003
  prefersTier: tier,
34689
36004
  forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
34690
36005
  failures,
34691
- upgradeCount
36006
+ upgradeCount,
36007
+ tryClimb: tryClimb ?? {
36008
+ failures: 0,
36009
+ notBefore: 0,
36010
+ lastRevert: null,
36011
+ trialFrom: null
36012
+ }
34692
36013
  });
34693
36014
  const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
34694
36015
  entry.session.pendingRenegotiation = {
@@ -34706,8 +36027,12 @@ var BrokerWebrtcServer = class {
34706
36027
  failures: Object.fromEntries(failures),
34707
36028
  upgradeCount,
34708
36029
  reason: decision?.cause ?? "unknown",
36030
+ trial: decision?.trial ?? false,
34709
36031
  lossRatio: decision ? Number(decision.lossRatio.toFixed(4)) : null,
34710
36032
  clientKbps: decision?.stall.clientKbps ?? null,
36033
+ sentKbps: decision?.stall.sentKbps ?? null,
36034
+ deliveryRatio: decision?.stall.deliveryRatio ?? null,
36035
+ deliveryWindowMs: decision?.stall.deliveryWindowMs ?? null,
34711
36036
  stalledForMs: decision?.stall.stalledForMs ?? null,
34712
36037
  serverSending: decision?.stall.serverSending ?? null
34713
36038
  }
@@ -34724,6 +36049,10 @@ var BrokerWebrtcServer = class {
34724
36049
  }
34725
36050
  if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
34726
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
+ }
34727
36056
  this.pruneLadderDecisions();
34728
36057
  if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
34729
36058
  if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
@@ -35884,7 +37213,10 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
35884
37213
  return {
35885
37214
  packetLossPercent: client.packetLossPercent,
35886
37215
  estimatedBandwidthKbps: client.estimatedBandwidthKbps,
35887
- 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 } : {}
35888
37220
  };
35889
37221
  } catch {
35890
37222
  return null;
@@ -35892,8 +37224,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
35892
37224
  clearTimeout(timer);
35893
37225
  }
35894
37226
  },
35895
- locateRecordedSegment: (args) => this.ctx.api.recording.locateSegment.query(args),
35896
- readRecordedSegmentBytes: async (args) => (await this.ctx.api.recording.readSegmentBytes.query(args)).data,
37227
+ locateRecordedSegment: (args) => this.ctx.api.recordingArchive.locateSegment.query(args),
37228
+ readRecordedSegmentBytes: async (args) => (await this.ctx.api.recordingArchive.readSegmentBytes.query(args)).data,
35897
37229
  readClipBytes: async (args) => {
35898
37230
  const res = await this.ctx.api.videoclips.readClipBytes.query({
35899
37231
  deviceId: args.deviceId,
@@ -35926,7 +37258,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
35926
37258
  },
35927
37259
  readRecordedGopBytes: async (args) => {
35928
37260
  try {
35929
- return await this.ctx.api.recording.readGopBytes.query({
37261
+ return await this.ctx.api.recordingArchive.readGopBytes.query({
35930
37262
  deviceId: args.deviceId,
35931
37263
  profile: args.profile,
35932
37264
  startMs: args.startMs,
@@ -35941,7 +37273,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
35941
37273
  });
35942
37274
  }
35943
37275
  return {
35944
- data: (await this.ctx.api.recording.readSegmentBytes.query({
37276
+ data: (await this.ctx.api.recordingArchive.readSegmentBytes.query({
35945
37277
  deviceId: args.deviceId,
35946
37278
  profile: args.profile,
35947
37279
  startMs: args.startMs