@camstack/addon-pipeline 1.2.244 → 1.2.246

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-CPwmR_XE.js → default-detection-model-BokEDZLK.js} +42 -1
  4. package/dist/{default-detection-model-Be8-TzIO.mjs → default-detection-model-b1HWNGyF.mjs} +31 -2
  5. package/dist/detection-pipeline/index.js +5 -16
  6. package/dist/detection-pipeline/index.mjs +5 -16
  7. package/dist/{dist-C1PeUaZn.js → dist-FF-KPXS7.js} +97 -10
  8. package/dist/{dist-D5j2hIVU.mjs → dist-XAp9FVoN.mjs} +97 -10
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-DHN6cJXp.js → node-C-KslKQf.js} +1 -1
  12. package/dist/{node-CpJcoSCa.mjs → node-umwnvSsV.mjs} +1 -1
  13. package/dist/pipeline-runner/index.js +38 -24
  14. package/dist/pipeline-runner/index.mjs +38 -24
  15. package/dist/{process-memory-CYMg9OKh.js → process-memory-ByvPOmQm.js} +1 -1
  16. package/dist/{process-memory-DO-CvzV3.mjs → process-memory-DkBPSh44.mjs} +1 -1
  17. package/dist/recorder/index.js +2 -9
  18. package/dist/recorder/index.mjs +2 -9
  19. package/dist/{segment-demux-js-B7Ot40Oz.js → segment-demux-js-BCzxobGz.js} +1 -1
  20. package/dist/{segment-demux-js-D6luJnWU.mjs → segment-demux-js-BodSgaxj.mjs} +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-CMr_02iL.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C2-mCqUf.mjs} +3 -3
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DVqDp5VD.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-CJunbWSo.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-Df8n-1_5.mjs → hostInit-BVEP1sys.mjs} +3 -3
  28. package/dist/stream-broker/index.js +567 -52
  29. package/dist/stream-broker/index.mjs +567 -52
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +2 -2
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DhmYsj50.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-m_mN1dE-.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-C1PeUaZn.js");
7
- const require_node = require("../node-DHN6cJXp.js");
6
+ const require_dist = require("../dist-FF-KPXS7.js");
7
+ const require_node = require("../node-C-KslKQf.js");
8
8
  const require_restream_intent = require("../restream-intent-C4adO0P8.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-B7Ot40Oz.js");
11
+ const require_segment_demux_js = require("../segment-demux-js-BCzxobGz.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");
@@ -4207,6 +4207,8 @@ var RtspListenServer = class RtspListenServer {
4207
4207
  brokerEnabled = /* @__PURE__ */ new Map();
4208
4208
  /** brokerId → warm-up closure registered alongside the restreamer */
4209
4209
  brokerWarmUps = /* @__PURE__ */ new Map();
4210
+ /** brokerId → prestream picture-window opener */
4211
+ brokerPrestreamOpeners = /* @__PURE__ */ new Map();
4210
4212
  /** Generate a random token for a stream path */
4211
4213
  static generateToken() {
4212
4214
  return (0, node_crypto.randomBytes)(16).toString("hex");
@@ -4217,7 +4219,7 @@ var RtspListenServer = class RtspListenServer {
4217
4219
  * If not, generate a new one.
4218
4220
  * Returns the token used.
4219
4221
  */
4220
- registerRestreamer(brokerId, restreamer, warmUp, existingToken) {
4222
+ registerRestreamer(brokerId, restreamer, warmUp, existingToken, prestreamOpener) {
4221
4223
  const oldToken = this.brokerTokens.get(brokerId);
4222
4224
  if (oldToken) {
4223
4225
  this.restreamers.delete(oldToken);
@@ -4228,6 +4230,7 @@ var RtspListenServer = class RtspListenServer {
4228
4230
  this.brokerTokens.set(brokerId, token);
4229
4231
  this.tokenBrokers.set(token, brokerId);
4230
4232
  if (warmUp) this.brokerWarmUps.set(brokerId, warmUp);
4233
+ if (prestreamOpener) this.brokerPrestreamOpeners.set(brokerId, prestreamOpener);
4231
4234
  return token;
4232
4235
  }
4233
4236
  unregisterRestreamer(brokerId) {
@@ -4238,6 +4241,7 @@ var RtspListenServer = class RtspListenServer {
4238
4241
  }
4239
4242
  this.brokerTokens.delete(brokerId);
4240
4243
  this.brokerWarmUps.delete(brokerId);
4244
+ this.brokerPrestreamOpeners.delete(brokerId);
4241
4245
  }
4242
4246
  /** Regenerate a random token for a broker. Returns the new token. */
4243
4247
  regenerateToken(brokerId) {
@@ -4338,8 +4342,10 @@ var RtspListenServer = class RtspListenServer {
4338
4342
  socket.destroy();
4339
4343
  return;
4340
4344
  }
4341
- const warmUp = brokerId ? this.brokerWarmUps.get(brokerId) : void 0;
4342
- const releaseWarmUp = warmUp ? warmUp() : null;
4345
+ const prestreamDial = declaration.intent === "prestream";
4346
+ const warmUp = brokerId && !prestreamDial ? this.brokerWarmUps.get(brokerId) : void 0;
4347
+ const opener = brokerId && prestreamDial ? this.brokerPrestreamOpeners.get(brokerId) : void 0;
4348
+ const releaseWarmUp = warmUp ? warmUp() : opener?.() ?? null;
4343
4349
  let released = false;
4344
4350
  const releaseOnce = () => {
4345
4351
  if (released) return;
@@ -10710,8 +10716,23 @@ var AudioCodecSession = class {
10710
10716
  var EncodedRingBuffer = class {
10711
10717
  packets = [];
10712
10718
  maxDurationMs;
10713
- constructor(maxDurationSec = 10) {
10719
+ maxBytes;
10720
+ bytes = 0;
10721
+ clampedByBytes = false;
10722
+ /**
10723
+ * @param maxDurationSec how much WALL TIME to keep.
10724
+ * @param maxBytes how much MEMORY that is allowed to cost. `0` = unbounded,
10725
+ * which is what this buffer was before and is why it needed a bound: the
10726
+ * retention is set in SECONDS, but what a second costs depends entirely on
10727
+ * which profile got armed. On the operator's fleet the cheap profile runs
10728
+ * at 34-118 kbps while the main stream runs at up to 11 Mbps — a 25:1
10729
+ * ratio — so one rule arming a clip on the main stream turns a 60-second
10730
+ * retention into 82 MB on a single camera, silently, in native memory.
10731
+ * A time limit cannot express that; only a byte limit can.
10732
+ */
10733
+ constructor(maxDurationSec = 10, maxBytes = 0) {
10714
10734
  this.maxDurationMs = maxDurationSec * 1e3;
10735
+ this.maxBytes = Math.max(0, maxBytes);
10715
10736
  }
10716
10737
  /** Update the buffer duration (in seconds). Evicts old packets immediately. */
10717
10738
  setDuration(seconds) {
@@ -10725,8 +10746,23 @@ var EncodedRingBuffer = class {
10725
10746
  /** Push a new packet into the buffer and evict expired ones. */
10726
10747
  push(packet) {
10727
10748
  this.packets.push(packet);
10749
+ this.bytes += packet.data.length;
10728
10750
  this.evict();
10729
10751
  }
10752
+ /** What the retained window currently costs, in bytes. */
10753
+ getBufferedBytes() {
10754
+ return this.bytes;
10755
+ }
10756
+ /**
10757
+ * True when the byte budget — not the clock — is what bounds this window.
10758
+ *
10759
+ * Worth asking rather than inferring from a short buffer: a window cut by
10760
+ * bytes holds LESS time than it was configured for, and a consumer that
10761
+ * believes the configured number will look for footage that was never kept.
10762
+ */
10763
+ isClampedByBytes() {
10764
+ return this.clampedByBytes;
10765
+ }
10730
10766
  /**
10731
10767
  * Get buffered packets, starting from the most recent keyframe.
10732
10768
  * Returns packets in chronological order (oldest first).
@@ -10768,6 +10804,8 @@ var EncodedRingBuffer = class {
10768
10804
  /** Clear all buffered packets. */
10769
10805
  clear() {
10770
10806
  this.packets.length = 0;
10807
+ this.bytes = 0;
10808
+ this.clampedByBytes = false;
10771
10809
  }
10772
10810
  /**
10773
10811
  * Remove packets older than maxDurationMs based on PTS.
@@ -10788,7 +10826,19 @@ var EncodedRingBuffer = class {
10788
10826
  const cutoff = newest - this.maxDurationMs;
10789
10827
  let removeCount = 0;
10790
10828
  while (removeCount < this.packets.length && (this.packets[removeCount].pts < cutoff || this.packets[removeCount].pts > newest)) removeCount++;
10791
- if (removeCount > 0) this.packets.splice(0, removeCount);
10829
+ if (removeCount > 0) {
10830
+ for (let i = 0; i < removeCount; i++) this.bytes -= this.packets[i].data.length;
10831
+ this.packets.splice(0, removeCount);
10832
+ }
10833
+ if (this.maxBytes > 0 && this.bytes > this.maxBytes) {
10834
+ this.clampedByBytes = true;
10835
+ let dropped = 0;
10836
+ while (dropped < this.packets.length && this.bytes > this.maxBytes) {
10837
+ this.bytes -= this.packets[dropped].data.length;
10838
+ dropped++;
10839
+ }
10840
+ if (dropped > 0) this.packets.splice(0, dropped);
10841
+ } else if (this.maxBytes > 0) this.clampedByBytes = false;
10792
10842
  }
10793
10843
  };
10794
10844
  //#endregion
@@ -12807,6 +12857,27 @@ var StreamBroker = class StreamBroker {
12807
12857
  /** Maximum allowed pre-buffer duration in seconds */
12808
12858
  static MAX_PRE_BUFFER_SEC = 30;
12809
12859
  /**
12860
+ * Ceiling for the notification clip ring, in seconds.
12861
+ *
12862
+ * Separate from `MAX_PRE_BUFFER_SEC` because the two rings live on different
12863
+ * cost axes: the pre-buffer holds the MAIN stream (the fleet's 22 warm
12864
+ * streams sum 107 Mbps), the clip ring holds the cheapest profile (the same
12865
+ * 22 sum 4.3 Mbps — a 25:1 ratio). A minute of the cheap one costs ~32 MB
12866
+ * across the whole fleet; a minute of the main one would cost ~800 MB.
12867
+ */
12868
+ static MAX_CLIP_RETENTION_SEC = 120;
12869
+ /**
12870
+ * What ONE camera's clip ring may cost, whatever profile ends up armed.
12871
+ *
12872
+ * The same 16 MiB the RTP pre-roll ring already uses, deliberately — one
12873
+ * number for "how much native memory one stream's history may hold". It
12874
+ * holds a full minute of any realistic cheap stream (up to ~2.2 Mbps) and
12875
+ * clamps a rule that arms the MAIN stream to about twelve seconds instead of
12876
+ * letting it take 82 MB. A window cut by this says so
12877
+ * (`isClampedByBytes`).
12878
+ */
12879
+ static CLIP_RING_MAX_BYTES = 16 * 1024 * 1024;
12880
+ /**
12810
12881
  * Retention for the decoder-seed GOP ring. Only ever needs to hold the
12811
12882
  * current GOP (a fresh keyframe evicts the previous GOP on read), so a few
12812
12883
  * seconds covers every realistic keyframe interval; cameras with a GOP longer
@@ -13380,9 +13451,36 @@ var StreamBroker = class StreamBroker {
13380
13451
  */
13381
13452
  hasAnyConsumer() {
13382
13453
  if (this.hasLiveIntent()) return true;
13454
+ if (this.prestreamHolds > 0) return true;
13383
13455
  return this.rtspRestreamer.getPrestreamSessionCount() > 0;
13384
13456
  }
13385
13457
  /**
13458
+ * Somebody is DESCRIBING a prestream and has no session yet.
13459
+ *
13460
+ * The RTSP dispatch reads the SDP before `addSession` runs, so a window that
13461
+ * waits for the session is too late: the consumer is handed an empty SDP and
13462
+ * quits. Measured against the live hub (2026-09-15 14:47): the window opened
13463
+ * on the attach and closed 42 ms later having emitted zero frames, because
13464
+ * ffmpeg had already given up.
13465
+ *
13466
+ * This is the prestream counterpart of `BrokerWarmUp` and its exact opposite
13467
+ * in effect: the warm-up subscribes demand, which DIALS; this opens the
13468
+ * picture, which never does. A battery camera is not touched.
13469
+ */
13470
+ openPrestreamWindow() {
13471
+ this.prestreamHolds += 1;
13472
+ this.checkDemand();
13473
+ let released = false;
13474
+ return () => {
13475
+ if (released) return;
13476
+ released = true;
13477
+ this.prestreamHolds = Math.max(0, this.prestreamHolds - 1);
13478
+ this.checkDemand();
13479
+ };
13480
+ }
13481
+ /** DESCRIBE-window prestream holds: sessions that do not exist yet. */
13482
+ prestreamHolds = 0;
13483
+ /**
13386
13484
  * Consumers that may DIAL. Every term of the former `hasDemand`, with the
13387
13485
  * RTSP session count filtered to exclude prestream dials, plus the wake hold.
13388
13486
  *
@@ -13542,6 +13640,31 @@ var StreamBroker = class StreamBroker {
13542
13640
  }, SUSPEND_GRACE_MS);
13543
13641
  }
13544
13642
  this.prestreamWanted = this.hasAnyConsumer() && !this.hasLiveIntent();
13643
+ this.reconcilePrestreamWindow();
13644
+ }
13645
+ /**
13646
+ * Open or close the prestream window to match {@link prestreamWanted}.
13647
+ *
13648
+ * This is the half that was missing. `prestreamWanted` has been computed
13649
+ * correctly since the feature shipped, but the only thing that ever STARTED
13650
+ * the pump was the lazy branch of `start()` — a DIAL, which needs live
13651
+ * intent, which a prestream session does not have by construction. So the
13652
+ * window opened only when something else had already woken the camera, and
13653
+ * `suspend()` closed it for good.
13654
+ *
13655
+ * Driving it from the consumer edge is what the contract already claimed:
13656
+ * the pump follows CONSUMERS, and it neither dials nor wakes nor holds
13657
+ * anything open (D173). `start()` is idempotent, so being called on every
13658
+ * demand check costs one encode per window and no more.
13659
+ */
13660
+ reconcilePrestreamWindow() {
13661
+ if (!this.prestreamPump) return;
13662
+ if (this.prestreamWanted && !this.prestreamWindowClosed) {
13663
+ const url = this.resolveCurrentSource()?.url ?? this.source?.url;
13664
+ if (url !== void 0) this.prestreamPump.start(url);
13665
+ return;
13666
+ }
13667
+ if (!this.prestreamWanted) this.prestreamPump.stop("no prestream consumer");
13545
13668
  }
13546
13669
  /**
13547
13670
  * Whether a prestream picture should be flowing right now: somebody is
@@ -13577,6 +13700,7 @@ var StreamBroker = class StreamBroker {
13577
13700
  }
13578
13701
  this.prestreamPump?.stop("broker suspended");
13579
13702
  this.prestreamWindowClosed = false;
13703
+ this.reconcilePrestreamWindow();
13580
13704
  this.preBuffer.clear();
13581
13705
  this.decoderSeedBuffer.clear();
13582
13706
  this.clipBuffer?.clear();
@@ -14584,15 +14708,31 @@ var StreamBroker = class StreamBroker {
14584
14708
  }
14585
14709
  /** Retain `seconds` of AnnexB video for notification clips (0 = stop, frees the ring). */
14586
14710
  setClipRetention(seconds) {
14587
- const clamped = Math.max(0, Math.min(seconds, StreamBroker.MAX_PRE_BUFFER_SEC));
14711
+ const clamped = Math.max(0, Math.min(seconds, StreamBroker.MAX_CLIP_RETENTION_SEC));
14588
14712
  if (clamped === this.clipRetentionSec) return;
14713
+ const previous = this.clipRetentionSec;
14589
14714
  this.clipRetentionSec = clamped;
14715
+ this.logger?.info(clamped === 0 ? "Clip retention off" : "Clip retention set", { meta: {
14716
+ seconds: clamped,
14717
+ requested: seconds,
14718
+ previousSeconds: previous
14719
+ } });
14590
14720
  if (clamped === 0) {
14591
14721
  this.clipBuffer = null;
14592
14722
  return;
14593
14723
  }
14594
14724
  if (this.clipBuffer) this.clipBuffer.setDuration(clamped);
14595
- else this.clipBuffer = new EncodedRingBuffer(clamped);
14725
+ else this.clipBuffer = new EncodedRingBuffer(clamped, StreamBroker.CLIP_RING_MAX_BYTES);
14726
+ }
14727
+ /**
14728
+ * Seconds this stream's clip ring is currently holding. `0` = no ring.
14729
+ *
14730
+ * A READ of what was applied, for the manager's own bound (how long a cut may
14731
+ * wait for its post-roll) and for the tests that pin the retention a camera's
14732
+ * notification rules ask for.
14733
+ */
14734
+ getClipRetentionSec() {
14735
+ return this.clipRetentionSec;
14596
14736
  }
14597
14737
  /**
14598
14738
  * The retained clip window, oldest first. Empty when retention is off.
@@ -16225,13 +16365,57 @@ function formatClockHHMM(at) {
16225
16365
  * reach `streaming` with audio detected, and the stats poll cadence. */
16226
16366
  var PROBE_STREAM_TIMEOUT_MS = 6e3;
16227
16367
  var PROBE_STREAM_POLL_MS = 250;
16228
- var CLIP_RETENTION_SEC = 25;
16368
+ /**
16369
+ * How much history a notification clip may be cut from on a camera the
16370
+ * notification centre has **not spoken about yet**.
16371
+ *
16372
+ * Retention is no longer flat: every camera holds what its own notification
16373
+ * rules ask for ({@link refreshClipRetentionPlan}), which is nothing at all on
16374
+ * a camera whose rules never cut footage. This number is what a camera holds
16375
+ * while that ask is UNKNOWN — before the first successful pull, and after a
16376
+ * pull that failed or answered with a rule set that has not loaded. Absence
16377
+ * must not read as zero: dropping a ring on boot, before the rules arrive,
16378
+ * loses the footage for exactly the first events after every restart, which is
16379
+ * when an operator is most likely to be watching. So an unknown camera keeps
16380
+ * what the whole fleet used to keep, and only an ANSWER can take it away.
16381
+ *
16382
+ * Sixty seconds, raised from 25 on 2026-09-15 after measuring what the
16383
+ * alternative sources can actually serve. The recordings' availability edge is
16384
+ * not a number but a PHASE: asking for the same 10-second window at different
16385
+ * anchors, full coverage arrived at −18 s on one camera and −26 s on two
16386
+ * others, depending on where the request falls in the 10-second segment cycle.
16387
+ * A notification window is `[anchor−4, anchor+6]`, so at 25 s the ring served
16388
+ * an anchor roughly 6–19 s old while the recordings began at 14–26 s — and on
16389
+ * an unlucky phase those bands did not meet, which is the silent "no clip" the
16390
+ * operator kept receiving. No fixed threshold closes a gap that opens and
16391
+ * closes with the segment cycle; margin over the WORST phase does.
16392
+ *
16393
+ * It is affordable only because this ring runs on the cheap profile: the
16394
+ * fleet's 22 cheap streams sum 4.3 Mbps, so a minute costs ~32 MB across every
16395
+ * camera — against ~800 MB for the same minute of the main stream. And it is
16396
+ * SAFE only because the ring is now bounded by bytes
16397
+ * (`StreamBroker.CLIP_RING_MAX_BYTES`): a rule that arms a clip on the main
16398
+ * stream is clamped to what 16 MiB holds rather than taking 82 MB.
16399
+ */
16400
+ var CLIP_RETENTION_UNKNOWN_SEC = 60;
16229
16401
  /** Cheapest-first — the profile a clip is cut from when the rule says nothing. */
16230
16402
  var CLIP_PROFILE_BY_COST = [
16231
16403
  "low",
16232
16404
  "mid",
16233
16405
  "high"
16234
16406
  ];
16407
+ /**
16408
+ * Head-room added to an OBSERVED ask (`preRoll + postRoll + this`).
16409
+ *
16410
+ * The notification centre derives the same shape from its own rules
16411
+ * (`NC_CLIP_DISPATCH_MARGIN_SEC`) and for the same reason: a cut is taken
16412
+ * seconds after the moment it is about — the row is queued, drained, and its
16413
+ * still is waited for — so a ring sized to the window alone drops the pre-roll
16414
+ * a heartbeat before the renderer asks for it. Deliberately NOT imported from
16415
+ * the notification addon: addons never import each other, and this is the
16416
+ * broker's own floor for a consumer that is not the notification centre.
16417
+ */
16418
+ var CLIP_OBSERVED_ASK_MARGIN_SEC = 10;
16235
16419
  /** How long `renderPreBufferClip` will wait for the post-roll to accumulate. */
16236
16420
  var CLIP_POST_ROLL_POLL_MS = 250;
16237
16421
  /**
@@ -18105,6 +18289,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18105
18289
  } else this.disabledDevices.delete(d.id);
18106
18290
  const live = cameras.filter((d) => !d.disabled);
18107
18291
  await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id, "poll")));
18292
+ await this.refreshClipRetentionPlan();
18108
18293
  await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
18109
18294
  const liveIds = new Set(live.map((d) => d.id));
18110
18295
  for (const deviceId of [...this.audioPlaneState.keys()]) if (!liveIds.has(deviceId)) this.audioPlaneState.delete(deviceId);
@@ -18643,8 +18828,8 @@ var StreamBrokerManager = class StreamBrokerManager {
18643
18828
  const assigned = CLIP_PROFILE_BY_COST.filter((p) => map[p] !== void 0);
18644
18829
  const wanted = pickClipProfile(assigned, input.profile);
18645
18830
  if (!wanted) throw new Error(`No profile assigned for device ${input.deviceId}`);
18646
- this.armClipRetention(input.deviceId, wanted);
18647
- const until = Math.min(input.aroundMs + input.postRollSec * 1e3, Date.now() + CLIP_RETENTION_SEC * 1e3);
18831
+ this.noteClipAsk(input.deviceId, wanted, input.preRollSec + input.postRollSec + CLIP_OBSERVED_ASK_MARGIN_SEC);
18832
+ const until = Math.min(input.aroundMs + input.postRollSec * 1e3, Date.now() + this.clipRetentionSecFor(input.deviceId) * 1e3);
18648
18833
  while (Date.now() < until) await new Promise((resolve) => setTimeout(resolve, CLIP_POST_ROLL_POLL_MS));
18649
18834
  const candidates = [wanted, ...assigned.filter((p) => p !== wanted)];
18650
18835
  let profile = null;
@@ -19182,7 +19367,8 @@ var StreamBrokerManager = class StreamBrokerManager {
19182
19367
  if (!sourceBroker) continue;
19183
19368
  const existingToken = this.persistedTokens.get(profileBrokerId);
19184
19369
  const warmUp = () => sourceBroker.onEncodedData(() => {});
19185
- this.rtspServer.registerRestreamer(profileBrokerId, sourceBroker.getRtspRestreamer(), warmUp, existingToken);
19370
+ const openPrestreamWindow = () => sourceBroker.openPrestreamWindow();
19371
+ this.rtspServer.registerRestreamer(profileBrokerId, sourceBroker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
19186
19372
  this.profileRestreamerBindings.set(profileBrokerId, sourceBrokerId);
19187
19373
  if (!existingToken) this.rtspProvider.persistTokens();
19188
19374
  }
@@ -20366,8 +20552,18 @@ var StreamBrokerManager = class StreamBrokerManager {
20366
20552
  this.applyClipRetention(deviceId);
20367
20553
  }
20368
20554
  /**
20369
- * Keep a clip window retained on the ONE profile clips are cut from — the
20370
- * cheapest assigned, which is what `pickClipProfile` defaults to.
20555
+ * Apply the retention THIS camera's notification rules actually ask for.
20556
+ *
20557
+ * Three inputs, one number:
20558
+ *
20559
+ * 1. the PLAN — `notificationRules.getClipRetentionAsks`, the rules' own
20560
+ * demand per camera ({@link refreshClipRetentionPlan});
20561
+ * 2. what a cut has been OBSERVED asking for on this camera
20562
+ * ({@link noteClipAsk}) — the old reactive arming, folded into the same
20563
+ * ask so there is one mechanism rather than two;
20564
+ * 3. nothing at all, when the plan has never arrived — see
20565
+ * {@link CLIP_RETENTION_UNKNOWN_SEC}. An unknown camera is not a camera
20566
+ * that needs nothing.
20371
20567
  *
20372
20568
  * Retention is opportunistic: it never dials a camera, it only fills while
20373
20569
  * something else already holds the stream up (usually the detection pipeline
@@ -20376,32 +20572,159 @@ var StreamBrokerManager = class StreamBrokerManager {
20376
20572
  */
20377
20573
  applyClipRetention(deviceId) {
20378
20574
  const map = this.assignments.get(deviceId)?.map ?? {};
20379
- const clipProfile = CLIP_PROFILE_BY_COST.find((p) => map[p] !== void 0);
20380
- const armed = this.clipRetentionArmed.get(deviceId);
20575
+ const resolved = this.resolveClipRetention(deviceId, map);
20381
20576
  for (const profile of require_dist.CAM_PROFILE_ORDER) {
20382
20577
  const camStreamId = map[profile];
20383
20578
  if (!camStreamId) continue;
20384
- const wanted = profile === clipProfile || armed?.has(profile) === true;
20385
- this.brokers.get(brokerIdFor(deviceId, camStreamId))?.setClipRetention(wanted ? CLIP_RETENTION_SEC : 0);
20579
+ const wanted = resolved.profiles.has(profile) ? resolved.seconds : 0;
20580
+ this.brokers.get(brokerIdFor(deviceId, camStreamId))?.setClipRetention(wanted);
20581
+ }
20582
+ this.reportClipRetention(deviceId, resolved);
20583
+ }
20584
+ /**
20585
+ * The seconds and the profiles this camera's ring should hold, and WHY.
20586
+ *
20587
+ * The MAX over every ask that reaches the camera — the asks describe the
20588
+ * rules, the bounding belongs here (and, below this, to the ring's own byte
20589
+ * budget, which is never raised for an ask).
20590
+ */
20591
+ resolveClipRetention(deviceId, map) {
20592
+ const cheapest = CLIP_PROFILE_BY_COST.find((p) => map[p] !== void 0);
20593
+ const observed = this.clipRetentionObserved.get(deviceId);
20594
+ const profiles = /* @__PURE__ */ new Set();
20595
+ let seconds = 0;
20596
+ let source;
20597
+ if (this.clipRetentionAsks === null) {
20598
+ seconds = CLIP_RETENTION_UNKNOWN_SEC;
20599
+ source = "unknown";
20600
+ } else {
20601
+ source = "rules";
20602
+ for (const ask of this.clipRetentionAsks) {
20603
+ if (ask.deviceId !== void 0 && ask.deviceId !== deviceId) continue;
20604
+ if (ask.seconds > seconds) seconds = ask.seconds;
20605
+ if (ask.profile !== void 0) profiles.add(ask.profile);
20606
+ }
20607
+ }
20608
+ if (observed !== void 0) for (const [profile, asked] of observed) {
20609
+ if (asked > seconds) {
20610
+ seconds = asked;
20611
+ source = source === "unknown" ? "unknown" : "observed";
20612
+ }
20613
+ profiles.add(profile);
20386
20614
  }
20615
+ if (seconds <= 0) return {
20616
+ seconds: 0,
20617
+ profiles: /* @__PURE__ */ new Set(),
20618
+ source
20619
+ };
20620
+ if (cheapest !== void 0) profiles.add(cheapest);
20621
+ return {
20622
+ seconds,
20623
+ profiles,
20624
+ source
20625
+ };
20387
20626
  }
20388
20627
  /**
20389
- * Profiles a rule has actually asked a clip from, beyond the cheapest one
20390
- * retained by default.
20628
+ * Say it, once per transition, per camera.
20391
20629
  *
20392
- * This must be REMEMBERED, not just applied: catalog reconcile re-runs
20393
- * `applyClipRetention` on a timer, and without this the arming was wiped
20394
- * within the minute — a rule asking for `high` fell back to `low` forever
20395
- * and looked like the choice did nothing.
20630
+ * This mechanism MOVES — a rule created, disabled or deleted changes what a
20631
+ * camera holds within one reconcile tick — and a retention that changes
20632
+ * silently is a retention nobody can correlate with a missing clip. The
20633
+ * broker's own `setClipRetention` logs the buffer it allocated; this line
20634
+ * says who asked for it.
20635
+ */
20636
+ reportClipRetention(deviceId, resolved) {
20637
+ const signature = `${resolved.seconds}:${resolved.source}:${[...resolved.profiles].sort().join(",")}`;
20638
+ const previous = this.clipRetentionApplied.get(deviceId);
20639
+ if (previous === signature) return;
20640
+ this.clipRetentionApplied.set(deviceId, signature);
20641
+ this.logger.info(resolved.seconds === 0 ? "clip retention: none — no notification rule on this camera cuts footage" : "clip retention set from the notification rules", {
20642
+ tags: { deviceId },
20643
+ meta: {
20644
+ seconds: resolved.seconds,
20645
+ profiles: [...resolved.profiles],
20646
+ source: resolved.source,
20647
+ previous: previous ?? "none"
20648
+ }
20649
+ });
20650
+ }
20651
+ /** Seconds this camera's ring is currently asked to hold (0 when none). */
20652
+ clipRetentionSecFor(deviceId) {
20653
+ return this.resolveClipRetention(deviceId, this.assignments.get(deviceId)?.map ?? {}).seconds;
20654
+ }
20655
+ /**
20656
+ * PULL the notification centre's per-camera asks and apply them.
20657
+ *
20658
+ * A pull, not a push: a camera's ring lives on the node that ingests it while
20659
+ * `notification-rules` is a singleton, so a push would have to enumerate the
20660
+ * cluster. It also makes the failure mode right — a pull that fails, or one
20661
+ * answered by a centre whose rules have not loaded, changes NOTHING (D49: a
20662
+ * read that fails must never be able to destroy work, and dropping a ring is
20663
+ * destroying work). Only an answer about a rule set that was actually read
20664
+ * can take retention away.
20665
+ *
20666
+ * Rides the catalog reconcile, which is also the D8 backstop that re-derives
20667
+ * after a rule is created, edited, enabled, disabled or deleted — bounded
20668
+ * staleness (one tick), never a mirror that only follows the rules at boot.
20396
20669
  */
20670
+ async refreshClipRetentionPlan() {
20671
+ const api = this.api;
20672
+ if (!api) return;
20673
+ let plan;
20674
+ try {
20675
+ plan = await api.notificationRules.getClipRetentionAsks.query({});
20676
+ } catch (err) {
20677
+ this.logger.debug("clip retention plan unavailable — keeping the one in force", { meta: {
20678
+ error: require_dist.errMsg(err),
20679
+ known: this.clipRetentionAsks !== null
20680
+ } });
20681
+ return;
20682
+ }
20683
+ if (!plan.rulesLoaded) {
20684
+ this.logger.debug("clip retention plan: the notification centre has not read its rules yet", { meta: { known: this.clipRetentionAsks !== null } });
20685
+ return;
20686
+ }
20687
+ this.clipRetentionAsks = plan.asks;
20688
+ for (const deviceId of this.assignments.keys()) this.applyClipRetention(deviceId);
20689
+ }
20397
20690
  /** Produced event artifacts waiting to be fetched by handle (D9/D18). */
20398
20691
  eventMedia = new EventMediaStore();
20399
- clipRetentionArmed = /* @__PURE__ */ new Map();
20400
- armClipRetention(deviceId, profile) {
20401
- const armed = this.clipRetentionArmed.get(deviceId) ?? /* @__PURE__ */ new Set();
20402
- if (armed.has(profile)) return;
20403
- armed.add(profile);
20404
- this.clipRetentionArmed.set(deviceId, armed);
20692
+ /**
20693
+ * The notification rules' own asks, per camera. `null` = never answered.
20694
+ *
20695
+ * `null` and `[]` are DIFFERENT and the difference is a camera's footage:
20696
+ * `[]` means the centre read its rules and none of them cut footage here,
20697
+ * `null` means nobody has said. Only the first one is allowed to zero a ring.
20698
+ */
20699
+ clipRetentionAsks = null;
20700
+ /**
20701
+ * What a cut was OBSERVED asking for, per camera and profile.
20702
+ *
20703
+ * The old `armClipRetention`, folded into the ask above so retention has ONE
20704
+ * input shape. It still exists because the plan describes the notification
20705
+ * rules and a cut can come from elsewhere (a clips API, an export, a rule
20706
+ * created a second ago on another node): a consumer that asked for a window
20707
+ * this ring did not hold gets it next time. It is REMEMBERED because the
20708
+ * reconcile re-applies retention on a timer, and without that the arming was
20709
+ * wiped within the minute — a rule asking for `high` fell back to `low`
20710
+ * forever and looked like the choice did nothing.
20711
+ */
20712
+ clipRetentionObserved = /* @__PURE__ */ new Map();
20713
+ /** Last reported (seconds, source, profiles) per camera — transitions only. */
20714
+ clipRetentionApplied = /* @__PURE__ */ new Map();
20715
+ /**
20716
+ * A cut asked this camera for `seconds` on `profile` — remember it.
20717
+ *
20718
+ * ONE ask, with both halves: the reactive arming used to carry the profile
20719
+ * and inherit the flat duration, so a consumer that needed a longer window
20720
+ * had no way to say so and a consumer that needed none still paid.
20721
+ */
20722
+ noteClipAsk(deviceId, profile, seconds) {
20723
+ const observed = this.clipRetentionObserved.get(deviceId) ?? /* @__PURE__ */ new Map();
20724
+ const bounded = Math.max(0, Math.min(seconds, CLIP_RETENTION_UNKNOWN_SEC));
20725
+ if ((observed.get(profile) ?? 0) >= bounded && this.clipRetentionObserved.has(deviceId)) return;
20726
+ observed.set(profile, Math.max(observed.get(profile) ?? 0, bounded));
20727
+ this.clipRetentionObserved.set(deviceId, observed);
20405
20728
  this.applyClipRetention(deviceId);
20406
20729
  }
20407
20730
  countPushConsumers(map, deviceId) {
@@ -20701,9 +21024,11 @@ var StreamBrokerManager = class StreamBrokerManager {
20701
21024
  return;
20702
21025
  }
20703
21026
  this.brokers.set(brokerId, broker);
21027
+ this.applyClipRetention(deviceId);
20704
21028
  const existingToken = this.persistedTokens.get(brokerId);
20705
21029
  const warmUp = () => broker.onEncodedData(() => {});
20706
- this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken);
21030
+ const openPrestreamWindow = () => broker.openPrestreamWindow();
21031
+ this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
20707
21032
  if (!existingToken) this.rtspProvider.persistTokens();
20708
21033
  await this.fanOutRegistration(deviceId, brokerId, cam, broker);
20709
21034
  this.syncProfileRestreamers(deviceId);
@@ -24277,6 +24602,18 @@ var AdaptiveSession = class AdaptiveSession {
24277
24602
  }
24278
24603
  static MAX_RTP_PAYLOAD = 1100;
24279
24604
  rtpPacketsSent = 0;
24605
+ /**
24606
+ * Cumulative RTP packets this session has put on the wire.
24607
+ *
24608
+ * Read by the adaptive ladder's stall detector: "the client received
24609
+ * nothing" only means the link is broken if the broker was SENDING at the
24610
+ * time. Without this half, a paused camera, a battery source between wakes
24611
+ * or any legitimately idle feed reads as a stall and walks the ladder to
24612
+ * the floor for nothing.
24613
+ */
24614
+ get rtpPacketsSentTotal() {
24615
+ return this.rtpPacketsSent;
24616
+ }
24280
24617
  rtpLogCounter = 0;
24281
24618
  /** One-shot guards for the "first video/audio RTP" debug markers. */
24282
24619
  firstVideoRtpLogged = false;
@@ -25064,6 +25401,105 @@ var LossTracker = class {
25064
25401
  }
25065
25402
  };
25066
25403
  //#endregion
25404
+ //#region src/stream-broker/webrtc/stall-tracker.ts
25405
+ /**
25406
+ * Windowed "the media stopped arriving" detector. Pure state machine: no
25407
+ * clock of its own, no side effects — `observe` is the only mutator and every
25408
+ * decision is a function of the samples it was given.
25409
+ */
25410
+ var StallTracker = class {
25411
+ config;
25412
+ /** When the current uninterrupted stall began; null when not stalling. */
25413
+ stallStartedAt = null;
25414
+ blackoutUntil = 0;
25415
+ lastEvidence = {
25416
+ clientKbps: null,
25417
+ stalledForMs: 0,
25418
+ serverSending: false
25419
+ };
25420
+ constructor(config) {
25421
+ this.config = config;
25422
+ }
25423
+ /** A tier change just happened — ignore the re-offer's own dead air. */
25424
+ markChanged(now) {
25425
+ this.stallStartedAt = null;
25426
+ this.blackoutUntil = now + this.config.waitAfterChangeMs;
25427
+ }
25428
+ /**
25429
+ * Fold one evaluation into the window. Returns nothing; ask `isStalled`.
25430
+ *
25431
+ * Three outcomes, and the middle one is the reason this is not a simple
25432
+ * boolean: a rate at or above `recoverKbpsFloor` CLEARS the stall, a rate
25433
+ * below `stallKbpsFloor` starts or extends it, and anything between the two
25434
+ * (or unknown, or with the server idle) HOLDS whatever the window already
25435
+ * believed. Holding is what stops a borderline link from oscillating.
25436
+ */
25437
+ observe(sample, now) {
25438
+ if (now < this.blackoutUntil) {
25439
+ this.stallStartedAt = null;
25440
+ this.lastEvidence = {
25441
+ clientKbps: sample.clientKbps,
25442
+ stalledForMs: 0,
25443
+ serverSending: sample.serverSending
25444
+ };
25445
+ return;
25446
+ }
25447
+ const kbps = sample.clientKbps;
25448
+ const age = sample.clientSampleAgeMs;
25449
+ if (!(kbps !== null && Number.isFinite(kbps) && age !== null && age <= this.config.maxClientSampleAgeMs) || kbps === null) {
25450
+ this.lastEvidence = {
25451
+ clientKbps: null,
25452
+ stalledForMs: this.stalledForMs(now),
25453
+ serverSending: sample.serverSending
25454
+ };
25455
+ return;
25456
+ }
25457
+ if (kbps >= this.config.recoverKbpsFloor) this.stallStartedAt = null;
25458
+ else if (kbps < this.config.stallKbpsFloor && sample.serverSending) this.stallStartedAt ??= now;
25459
+ this.lastEvidence = {
25460
+ clientKbps: kbps,
25461
+ stalledForMs: this.stalledForMs(now),
25462
+ serverSending: sample.serverSending
25463
+ };
25464
+ }
25465
+ stalledForMs(now) {
25466
+ return this.stallStartedAt === null ? 0 : now - this.stallStartedAt;
25467
+ }
25468
+ /** True once the stall has persisted for `stallConfirmMs`. */
25469
+ isStalled(now) {
25470
+ if (now < this.blackoutUntil) return false;
25471
+ return this.stalledForMs(now) >= this.config.stallConfirmMs;
25472
+ }
25473
+ /**
25474
+ * May the ladder PROMOTE on this evaluation?
25475
+ *
25476
+ * False when the last fresh measurement was below `recoverKbpsFloor` —
25477
+ * either a stall building, or a link crawling along in the dead band.
25478
+ * Promoting there spends bandwidth the session has just been shown not to
25479
+ * have, and the loss path cannot object: a stream delivering almost nothing
25480
+ * reports almost no loss. Same doctrine as `LossTracker.hasEvidence` — a
25481
+ * climb needs POSITIVE evidence, not the absence of a complaint.
25482
+ *
25483
+ * Unknown does not block. A deployment with no client telemetry at all must
25484
+ * behave exactly as it did before this tracker existed; the loss path's own
25485
+ * `hasEvidence` is the gate there.
25486
+ */
25487
+ isClimbSafe(now) {
25488
+ if (now < this.blackoutUntil) return true;
25489
+ if (this.stallStartedAt !== null) return false;
25490
+ const last = this.lastEvidence.clientKbps;
25491
+ if (last === null) return true;
25492
+ return last >= this.config.recoverKbpsFloor;
25493
+ }
25494
+ /** What the last `observe` measured — for the switch's log line. */
25495
+ evidence(now) {
25496
+ return {
25497
+ ...this.lastEvidence,
25498
+ stalledForMs: this.stalledForMs(now)
25499
+ };
25500
+ }
25501
+ };
25502
+ //#endregion
25067
25503
  //#region src/stream-broker/webrtc/adaptive-controller.ts
25068
25504
  var TIER_ORDER = require_dist.CAM_PROFILE_ORDER;
25069
25505
  /**
@@ -25085,6 +25521,7 @@ var TIER_ORDER = require_dist.CAM_PROFILE_ORDER;
25085
25521
  var AdaptiveController = class {
25086
25522
  opts;
25087
25523
  tracker;
25524
+ stalls;
25088
25525
  tier;
25089
25526
  mtuReduced = false;
25090
25527
  transcodeEngaged = false;
@@ -25103,6 +25540,7 @@ var AdaptiveController = class {
25103
25540
  constructor(opts) {
25104
25541
  this.opts = opts;
25105
25542
  this.tracker = new LossTracker(opts.config);
25543
+ this.stalls = new StallTracker(opts.config);
25106
25544
  this.tier = opts.initialTier;
25107
25545
  this.failures = new Map(opts.priorFailures ?? []);
25108
25546
  this.upgradeCount = Math.max(0, opts.priorUpgradeCount ?? 0);
@@ -25125,6 +25563,19 @@ var AdaptiveController = class {
25125
25563
  this.tracker.track(sample, now);
25126
25564
  }
25127
25565
  /**
25566
+ * Fold one TRANSPORT measurement into the ladder — what the client actually
25567
+ * received, and whether the broker was sending at the time.
25568
+ *
25569
+ * This is the half the ladder was missing. Packet loss cannot see a freeze
25570
+ * (a stream delivering nothing reports 0 % loss, because there are no gaps
25571
+ * in a sequence that received no packets), and the client's downlink hint
25572
+ * cannot see one either — it is a one-shot HTTP burst measured at connect
25573
+ * time, on a path the media does not take. See `stall-tracker.ts`.
25574
+ */
25575
+ onThroughput(sample, now) {
25576
+ this.stalls.observe(sample, now);
25577
+ }
25578
+ /**
25128
25579
  * Freeze the ladder for this evaluation instead of ticking it.
25129
25580
  *
25130
25581
  * Called while the session's track is NOT carrying the live source — D42
@@ -25145,6 +25596,7 @@ var AdaptiveController = class {
25145
25596
  this.lastChangeAt = now;
25146
25597
  }
25147
25598
  this.healthySince = now;
25599
+ this.stalls.markChanged(now);
25148
25600
  }
25149
25601
  tick(now) {
25150
25602
  if (!this.armed) {
@@ -25152,9 +25604,18 @@ var AdaptiveController = class {
25152
25604
  this.healthySince = now;
25153
25605
  this.lastChangeAt = now;
25154
25606
  }
25607
+ if (this.stalls.isStalled(now)) {
25608
+ this.healthySince = now;
25609
+ this.handleFailing(now, "stall");
25610
+ return;
25611
+ }
25155
25612
  if (this.tracker.isFailing(now)) {
25156
25613
  this.healthySince = now;
25157
- this.handleFailing(now);
25614
+ this.handleFailing(now, "loss");
25615
+ return;
25616
+ }
25617
+ if (!this.stalls.isClimbSafe(now)) {
25618
+ this.healthySince = now;
25158
25619
  return;
25159
25620
  }
25160
25621
  if (!this.tracker.hasEvidence(now)) {
@@ -25180,26 +25641,36 @@ var AdaptiveController = class {
25180
25641
  commitChange(now) {
25181
25642
  this.lastChangeAt = now;
25182
25643
  this.tracker.markChanged(now);
25644
+ this.stalls.markChanged(now);
25183
25645
  }
25184
- handleFailing(now) {
25646
+ /** The measurement this decision rests on, snapshotted for the log line. */
25647
+ decisionAt(now, cause) {
25648
+ return {
25649
+ cause,
25650
+ lossRatio: this.tracker.lossRatio(),
25651
+ stall: this.stalls.evidence(now)
25652
+ };
25653
+ }
25654
+ handleFailing(now, cause) {
25655
+ const decision = this.decisionAt(now, cause);
25185
25656
  const idx = TIER_ORDER.indexOf(this.tier);
25186
25657
  if (idx < TIER_ORDER.length - 1) {
25187
25658
  const failed = this.tier;
25188
25659
  this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
25189
25660
  this.tier = TIER_ORDER[idx + 1];
25190
- this.opts.onSwitchTier(this.tier);
25661
+ this.opts.onSwitchTier(this.tier, decision);
25191
25662
  this.commitChange(now);
25192
25663
  return;
25193
25664
  }
25194
25665
  if (!this.mtuReduced) {
25195
25666
  this.mtuReduced = true;
25196
- this.opts.onReduceMtu();
25667
+ this.opts.onReduceMtu(decision);
25197
25668
  this.commitChange(now);
25198
25669
  return;
25199
25670
  }
25200
25671
  if (!this.transcodeEngaged) {
25201
25672
  this.transcodeEngaged = true;
25202
- this.opts.onTranscodeDown();
25673
+ this.opts.onTranscodeDown(decision);
25203
25674
  this.commitChange(now);
25204
25675
  }
25205
25676
  }
@@ -25213,7 +25684,7 @@ var AdaptiveController = class {
25213
25684
  this.mtuReduced = false;
25214
25685
  this.transcodeEngaged = false;
25215
25686
  this.upgradeCount += 1;
25216
- this.opts.onSwitchTier(next);
25687
+ this.opts.onSwitchTier(next, this.decisionAt(now, "healthy"));
25217
25688
  this.commitChange(now);
25218
25689
  this.healthySince = now;
25219
25690
  }
@@ -29354,7 +29825,12 @@ var ADAPTIVE_CONFIG = {
29354
29825
  upgradeHealthyGrowthFactor: 3,
29355
29826
  maxProgressiveUpgradeHealthyMs: 3e4,
29356
29827
  minTimeBetweenChangesMs: 4e3,
29357
- maxUpgradeBackoffMs: 9e5
29828
+ maxUpgradeBackoffMs: 9e5,
29829
+ stallKbpsFloor: 64,
29830
+ stallConfirmMs: 6e3,
29831
+ recoverKbpsFloor: 128,
29832
+ waitAfterChangeMs: 8e3,
29833
+ maxClientSampleAgeMs: 1e4
29358
29834
  };
29359
29835
  /**
29360
29836
  * True when `broker` is an in-process StreamBroker exposing the Opus egress
@@ -29498,7 +29974,7 @@ var BrokerWebrtcServer = class {
29498
29974
  staticIceServers;
29499
29975
  icePortRange;
29500
29976
  iceAdditionalHostAddresses;
29501
- clientLossPct;
29977
+ clientNetworkStats;
29502
29978
  locateRecordedSegment;
29503
29979
  readRecordedSegmentBytes;
29504
29980
  readRecordedGopBytes;
@@ -29565,7 +30041,7 @@ var BrokerWebrtcServer = class {
29565
30041
  this.staticIceServers = options.iceServers;
29566
30042
  this.icePortRange = options.icePortRange;
29567
30043
  this.iceAdditionalHostAddresses = options.iceAdditionalHostAddresses;
29568
- this.clientLossPct = options.clientLossPct;
30044
+ this.clientNetworkStats = options.clientNetworkStats;
29569
30045
  this.locateRecordedSegment = options.locateRecordedSegment;
29570
30046
  this.readRecordedSegmentBytes = options.readRecordedSegmentBytes;
29571
30047
  this.readRecordedGopBytes = options.readRecordedGopBytes;
@@ -30503,12 +30979,27 @@ var BrokerWebrtcServer = class {
30503
30979
  priorFailures,
30504
30980
  priorUpgradeCount,
30505
30981
  config: ADAPTIVE_CONFIG,
30506
- onSwitchTier: (tier) => {
30507
- this.switchSessionTier(entry, deviceId, tier, sessionId);
30982
+ onSwitchTier: (tier, decision) => {
30983
+ this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
30508
30984
  },
30509
- onReduceMtu: () => entry.session.setRepacketizerMtu(AdaptiveSession.ADAPTIVE_LOW_MTU),
30510
- onTranscodeDown: () => {
30511
- this.switchSessionTier(entry, deviceId, "low", sessionId, true);
30985
+ onReduceMtu: (decision) => {
30986
+ entry.session.setRepacketizerMtu(AdaptiveSession.ADAPTIVE_LOW_MTU);
30987
+ this.logger.info("adaptive: reducing repacketizer MTU", {
30988
+ tags: { deviceId },
30989
+ meta: {
30990
+ sessionId,
30991
+ deviceId,
30992
+ mtu: AdaptiveSession.ADAPTIVE_LOW_MTU,
30993
+ reason: decision.cause,
30994
+ lossRatio: Number(decision.lossRatio.toFixed(4)),
30995
+ clientKbps: decision.stall.clientKbps,
30996
+ stalledForMs: decision.stall.stalledForMs,
30997
+ serverSending: decision.stall.serverSending
30998
+ }
30999
+ });
31000
+ },
31001
+ onTranscodeDown: (decision) => {
31002
+ this.switchSessionTier(entry, deviceId, "low", sessionId, true, decision);
30512
31003
  }
30513
31004
  });
30514
31005
  entry.session.onReceiverReport((s) => controller.onLoss(s, Date.now()));
@@ -30516,6 +31007,9 @@ var BrokerWebrtcServer = class {
30516
31007
  let clientHighestSequence = 0;
30517
31008
  let clientPacketsLost = 0;
30518
31009
  let tickInFlight = false;
31010
+ /** RTP packets sent as of the previous evaluation — the "was the broker
31011
+ * sending?" half of the stall verdict. */
31012
+ let lastRtpSent = entry.session.rtpPacketsSentTotal;
30519
31013
  const CLIENT_LOSS_SPAN = 1e3;
30520
31014
  entry.adaptiveTimer = setInterval(() => {
30521
31015
  if (tickInFlight) return;
@@ -30524,10 +31018,20 @@ var BrokerWebrtcServer = class {
30524
31018
  try {
30525
31019
  if (entry.timeline?.currentMode === "recorded") {
30526
31020
  controller.suspend(Date.now());
31021
+ lastRtpSent = entry.session.rtpPacketsSentTotal;
30527
31022
  return;
30528
31023
  }
30529
- const pct = this.clientLossPct ? await this.clientLossPct(deviceId) : null;
31024
+ const client = this.clientNetworkStats ? await this.clientNetworkStats(deviceId) : null;
30530
31025
  const now = Date.now();
31026
+ const rtpSent = entry.session.rtpPacketsSentTotal;
31027
+ const serverSending = rtpSent > lastRtpSent;
31028
+ lastRtpSent = rtpSent;
31029
+ controller.onThroughput({
31030
+ clientKbps: client ? client.estimatedBandwidthKbps : null,
31031
+ clientSampleAgeMs: client ? Math.max(0, now - client.lastUpdated) : null,
31032
+ serverSending
31033
+ }, now);
31034
+ const pct = client?.packetLossPercent ?? null;
30531
31035
  if (pct !== null && pct !== void 0) {
30532
31036
  clientHighestSequence += CLIENT_LOSS_SPAN;
30533
31037
  clientPacketsLost += Math.round(Math.max(0, Math.min(100, pct)) / 100 * CLIENT_LOSS_SPAN);
@@ -30556,7 +31060,7 @@ var BrokerWebrtcServer = class {
30556
31060
  * consumes the recorded intent to pin the recreated session to `tier` and
30557
31061
  * apply the reduced transcode profile when `forceTranscodeDown` is set.
30558
31062
  */
30559
- switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
31063
+ switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false, decision) {
30560
31064
  const prev = this.adaptiveIntent.get(sessionId);
30561
31065
  const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
30562
31066
  const upgradeCount = entry.adaptiveController?.successfulUpgradeCount ?? prev?.upgradeCount ?? 0;
@@ -30580,7 +31084,12 @@ var BrokerWebrtcServer = class {
30580
31084
  forceTranscodeDown,
30581
31085
  epoch,
30582
31086
  failures: Object.fromEntries(failures),
30583
- upgradeCount
31087
+ upgradeCount,
31088
+ reason: decision?.cause ?? "unknown",
31089
+ lossRatio: decision ? Number(decision.lossRatio.toFixed(4)) : null,
31090
+ clientKbps: decision?.stall.clientKbps ?? null,
31091
+ stalledForMs: decision?.stall.stalledForMs ?? null,
31092
+ serverSending: decision?.stall.serverSending ?? null
30584
31093
  }
30585
31094
  });
30586
31095
  }
@@ -31652,11 +32161,17 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
31652
32161
  return [];
31653
32162
  }
31654
32163
  },
31655
- clientLossPct: async (deviceId) => {
32164
+ clientNetworkStats: async (deviceId) => {
31656
32165
  const ctrl = new AbortController();
31657
32166
  const timer = setTimeout(() => ctrl.abort(), NETWORK_QUALITY_STATS_TIMEOUT_MS);
31658
32167
  try {
31659
- return (await this.ctx.api.networkQuality.getDeviceStats.query({ deviceId }, { signal: ctrl.signal }))?.client?.packetLossPercent ?? null;
32168
+ const client = (await this.ctx.api.networkQuality.getDeviceStats.query({ deviceId }, { signal: ctrl.signal }))?.client;
32169
+ if (!client) return null;
32170
+ return {
32171
+ packetLossPercent: client.packetLossPercent,
32172
+ estimatedBandwidthKbps: client.estimatedBandwidthKbps,
32173
+ lastUpdated: client.lastUpdated
32174
+ };
31660
32175
  } catch {
31661
32176
  return null;
31662
32177
  } finally {