@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
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { A as RingBuffer, B as declareLogChannel, Bt as buildFfmpegArgs$1, E as RATE_CONTROL_TIGHT, Et as resolveEgressDecodeHwAccel, Gt as CAM_PROFILE_ORDER, H as deriveBatteryPresence, Ht as isSoftwareDecode, I as batteryCapability, It as webrtcSessionCapability, K as egressTranscodeSharingKey, Kt as DeviceFeature, L as cameraStreamsCapability, Lt as errMsg, M as addonWidgetsSourceCapability, Nt as streamBrokerCapability, Qt as expandAudioChunkToF32le, R as createHwAccelCache, Rt as AUDIO_PRESETS, T as RATE_CONTROL_RELAXED, Ut as BOOT_RECOVERY_BACKOFF_MS, Vt as invocationFromEncodeProfile, Wt as BaseAddon, Yt as createEvent, _n as record, an as parseProfileBrokerId, at as logChannelsCapability, bn as EventCategory, cn as _enum, dn as discriminatedUnion, en as isEvent, h as EncodeProfileSchema, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, ln as array, mn as number, nn as makeSourceBrokerId, pn as literal, q as egressTransportFromRequest, qt as DeviceType, rn as nodePin, st as maskUrlCredentials, tn as makeProfileBrokerId, un as boolean, v as G711_SCALE_CORRECTION_DB, vn as string, xt as rankStreams, yn as union, z as createLogChannelsProvider } from "../dist-D5j2hIVU.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-CpJcoSCa.mjs";
2
+ import { A as RingBuffer, B as declareLogChannel, Bt as buildFfmpegArgs$1, E as RATE_CONTROL_TIGHT, Et as resolveEgressDecodeHwAccel, Gt as CAM_PROFILE_ORDER, H as deriveBatteryPresence, Ht as isSoftwareDecode, I as batteryCapability, It as webrtcSessionCapability, K as egressTranscodeSharingKey, Kt as DeviceFeature, L as cameraStreamsCapability, Lt as errMsg, M as addonWidgetsSourceCapability, Nt as streamBrokerCapability, Qt as expandAudioChunkToF32le, R as createHwAccelCache, Rt as AUDIO_PRESETS, T as RATE_CONTROL_RELAXED, Ut as BOOT_RECOVERY_BACKOFF_MS, Vt as invocationFromEncodeProfile, Wt as BaseAddon, Yt as createEvent, _n as record, an as parseProfileBrokerId, at as logChannelsCapability, bn as EventCategory, cn as _enum, dn as discriminatedUnion, en as isEvent, h as EncodeProfileSchema, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, ln as array, mn as number, nn as makeSourceBrokerId, pn as literal, q as egressTransportFromRequest, qt as DeviceType, rn as nodePin, st as maskUrlCredentials, tn as makeProfileBrokerId, un as boolean, v as G711_SCALE_CORRECTION_DB, vn as string, xt as rankStreams, yn as union, z as createLogChannelsProvider } from "../dist-XAp9FVoN.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-umwnvSsV.mjs";
4
4
  import { c as PREBUFFER_MAX_MS, d as resolvePrebuffer, i as parseRestreamPath, l as capPreRollRequestMs, n as NO_DECLARATION, u as prebufferRetentionMs } from "../restream-intent-MeJhGCK9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
- import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-D6luJnWU.mjs";
7
+ import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-BodSgaxj.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -4203,6 +4203,8 @@ var RtspListenServer = class RtspListenServer {
4203
4203
  brokerEnabled = /* @__PURE__ */ new Map();
4204
4204
  /** brokerId → warm-up closure registered alongside the restreamer */
4205
4205
  brokerWarmUps = /* @__PURE__ */ new Map();
4206
+ /** brokerId → prestream picture-window opener */
4207
+ brokerPrestreamOpeners = /* @__PURE__ */ new Map();
4206
4208
  /** Generate a random token for a stream path */
4207
4209
  static generateToken() {
4208
4210
  return randomBytes(16).toString("hex");
@@ -4213,7 +4215,7 @@ var RtspListenServer = class RtspListenServer {
4213
4215
  * If not, generate a new one.
4214
4216
  * Returns the token used.
4215
4217
  */
4216
- registerRestreamer(brokerId, restreamer, warmUp, existingToken) {
4218
+ registerRestreamer(brokerId, restreamer, warmUp, existingToken, prestreamOpener) {
4217
4219
  const oldToken = this.brokerTokens.get(brokerId);
4218
4220
  if (oldToken) {
4219
4221
  this.restreamers.delete(oldToken);
@@ -4224,6 +4226,7 @@ var RtspListenServer = class RtspListenServer {
4224
4226
  this.brokerTokens.set(brokerId, token);
4225
4227
  this.tokenBrokers.set(token, brokerId);
4226
4228
  if (warmUp) this.brokerWarmUps.set(brokerId, warmUp);
4229
+ if (prestreamOpener) this.brokerPrestreamOpeners.set(brokerId, prestreamOpener);
4227
4230
  return token;
4228
4231
  }
4229
4232
  unregisterRestreamer(brokerId) {
@@ -4234,6 +4237,7 @@ var RtspListenServer = class RtspListenServer {
4234
4237
  }
4235
4238
  this.brokerTokens.delete(brokerId);
4236
4239
  this.brokerWarmUps.delete(brokerId);
4240
+ this.brokerPrestreamOpeners.delete(brokerId);
4237
4241
  }
4238
4242
  /** Regenerate a random token for a broker. Returns the new token. */
4239
4243
  regenerateToken(brokerId) {
@@ -4334,8 +4338,10 @@ var RtspListenServer = class RtspListenServer {
4334
4338
  socket.destroy();
4335
4339
  return;
4336
4340
  }
4337
- const warmUp = brokerId ? this.brokerWarmUps.get(brokerId) : void 0;
4338
- const releaseWarmUp = warmUp ? warmUp() : null;
4341
+ const prestreamDial = declaration.intent === "prestream";
4342
+ const warmUp = brokerId && !prestreamDial ? this.brokerWarmUps.get(brokerId) : void 0;
4343
+ const opener = brokerId && prestreamDial ? this.brokerPrestreamOpeners.get(brokerId) : void 0;
4344
+ const releaseWarmUp = warmUp ? warmUp() : opener?.() ?? null;
4339
4345
  let released = false;
4340
4346
  const releaseOnce = () => {
4341
4347
  if (released) return;
@@ -10706,8 +10712,23 @@ var AudioCodecSession = class {
10706
10712
  var EncodedRingBuffer = class {
10707
10713
  packets = [];
10708
10714
  maxDurationMs;
10709
- constructor(maxDurationSec = 10) {
10715
+ maxBytes;
10716
+ bytes = 0;
10717
+ clampedByBytes = false;
10718
+ /**
10719
+ * @param maxDurationSec how much WALL TIME to keep.
10720
+ * @param maxBytes how much MEMORY that is allowed to cost. `0` = unbounded,
10721
+ * which is what this buffer was before and is why it needed a bound: the
10722
+ * retention is set in SECONDS, but what a second costs depends entirely on
10723
+ * which profile got armed. On the operator's fleet the cheap profile runs
10724
+ * at 34-118 kbps while the main stream runs at up to 11 Mbps — a 25:1
10725
+ * ratio — so one rule arming a clip on the main stream turns a 60-second
10726
+ * retention into 82 MB on a single camera, silently, in native memory.
10727
+ * A time limit cannot express that; only a byte limit can.
10728
+ */
10729
+ constructor(maxDurationSec = 10, maxBytes = 0) {
10710
10730
  this.maxDurationMs = maxDurationSec * 1e3;
10731
+ this.maxBytes = Math.max(0, maxBytes);
10711
10732
  }
10712
10733
  /** Update the buffer duration (in seconds). Evicts old packets immediately. */
10713
10734
  setDuration(seconds) {
@@ -10721,8 +10742,23 @@ var EncodedRingBuffer = class {
10721
10742
  /** Push a new packet into the buffer and evict expired ones. */
10722
10743
  push(packet) {
10723
10744
  this.packets.push(packet);
10745
+ this.bytes += packet.data.length;
10724
10746
  this.evict();
10725
10747
  }
10748
+ /** What the retained window currently costs, in bytes. */
10749
+ getBufferedBytes() {
10750
+ return this.bytes;
10751
+ }
10752
+ /**
10753
+ * True when the byte budget — not the clock — is what bounds this window.
10754
+ *
10755
+ * Worth asking rather than inferring from a short buffer: a window cut by
10756
+ * bytes holds LESS time than it was configured for, and a consumer that
10757
+ * believes the configured number will look for footage that was never kept.
10758
+ */
10759
+ isClampedByBytes() {
10760
+ return this.clampedByBytes;
10761
+ }
10726
10762
  /**
10727
10763
  * Get buffered packets, starting from the most recent keyframe.
10728
10764
  * Returns packets in chronological order (oldest first).
@@ -10764,6 +10800,8 @@ var EncodedRingBuffer = class {
10764
10800
  /** Clear all buffered packets. */
10765
10801
  clear() {
10766
10802
  this.packets.length = 0;
10803
+ this.bytes = 0;
10804
+ this.clampedByBytes = false;
10767
10805
  }
10768
10806
  /**
10769
10807
  * Remove packets older than maxDurationMs based on PTS.
@@ -10784,7 +10822,19 @@ var EncodedRingBuffer = class {
10784
10822
  const cutoff = newest - this.maxDurationMs;
10785
10823
  let removeCount = 0;
10786
10824
  while (removeCount < this.packets.length && (this.packets[removeCount].pts < cutoff || this.packets[removeCount].pts > newest)) removeCount++;
10787
- if (removeCount > 0) this.packets.splice(0, removeCount);
10825
+ if (removeCount > 0) {
10826
+ for (let i = 0; i < removeCount; i++) this.bytes -= this.packets[i].data.length;
10827
+ this.packets.splice(0, removeCount);
10828
+ }
10829
+ if (this.maxBytes > 0 && this.bytes > this.maxBytes) {
10830
+ this.clampedByBytes = true;
10831
+ let dropped = 0;
10832
+ while (dropped < this.packets.length && this.bytes > this.maxBytes) {
10833
+ this.bytes -= this.packets[dropped].data.length;
10834
+ dropped++;
10835
+ }
10836
+ if (dropped > 0) this.packets.splice(0, dropped);
10837
+ } else if (this.maxBytes > 0) this.clampedByBytes = false;
10788
10838
  }
10789
10839
  };
10790
10840
  //#endregion
@@ -12803,6 +12853,27 @@ var StreamBroker = class StreamBroker {
12803
12853
  /** Maximum allowed pre-buffer duration in seconds */
12804
12854
  static MAX_PRE_BUFFER_SEC = 30;
12805
12855
  /**
12856
+ * Ceiling for the notification clip ring, in seconds.
12857
+ *
12858
+ * Separate from `MAX_PRE_BUFFER_SEC` because the two rings live on different
12859
+ * cost axes: the pre-buffer holds the MAIN stream (the fleet's 22 warm
12860
+ * streams sum 107 Mbps), the clip ring holds the cheapest profile (the same
12861
+ * 22 sum 4.3 Mbps — a 25:1 ratio). A minute of the cheap one costs ~32 MB
12862
+ * across the whole fleet; a minute of the main one would cost ~800 MB.
12863
+ */
12864
+ static MAX_CLIP_RETENTION_SEC = 120;
12865
+ /**
12866
+ * What ONE camera's clip ring may cost, whatever profile ends up armed.
12867
+ *
12868
+ * The same 16 MiB the RTP pre-roll ring already uses, deliberately — one
12869
+ * number for "how much native memory one stream's history may hold". It
12870
+ * holds a full minute of any realistic cheap stream (up to ~2.2 Mbps) and
12871
+ * clamps a rule that arms the MAIN stream to about twelve seconds instead of
12872
+ * letting it take 82 MB. A window cut by this says so
12873
+ * (`isClampedByBytes`).
12874
+ */
12875
+ static CLIP_RING_MAX_BYTES = 16 * 1024 * 1024;
12876
+ /**
12806
12877
  * Retention for the decoder-seed GOP ring. Only ever needs to hold the
12807
12878
  * current GOP (a fresh keyframe evicts the previous GOP on read), so a few
12808
12879
  * seconds covers every realistic keyframe interval; cameras with a GOP longer
@@ -13376,9 +13447,36 @@ var StreamBroker = class StreamBroker {
13376
13447
  */
13377
13448
  hasAnyConsumer() {
13378
13449
  if (this.hasLiveIntent()) return true;
13450
+ if (this.prestreamHolds > 0) return true;
13379
13451
  return this.rtspRestreamer.getPrestreamSessionCount() > 0;
13380
13452
  }
13381
13453
  /**
13454
+ * Somebody is DESCRIBING a prestream and has no session yet.
13455
+ *
13456
+ * The RTSP dispatch reads the SDP before `addSession` runs, so a window that
13457
+ * waits for the session is too late: the consumer is handed an empty SDP and
13458
+ * quits. Measured against the live hub (2026-09-15 14:47): the window opened
13459
+ * on the attach and closed 42 ms later having emitted zero frames, because
13460
+ * ffmpeg had already given up.
13461
+ *
13462
+ * This is the prestream counterpart of `BrokerWarmUp` and its exact opposite
13463
+ * in effect: the warm-up subscribes demand, which DIALS; this opens the
13464
+ * picture, which never does. A battery camera is not touched.
13465
+ */
13466
+ openPrestreamWindow() {
13467
+ this.prestreamHolds += 1;
13468
+ this.checkDemand();
13469
+ let released = false;
13470
+ return () => {
13471
+ if (released) return;
13472
+ released = true;
13473
+ this.prestreamHolds = Math.max(0, this.prestreamHolds - 1);
13474
+ this.checkDemand();
13475
+ };
13476
+ }
13477
+ /** DESCRIBE-window prestream holds: sessions that do not exist yet. */
13478
+ prestreamHolds = 0;
13479
+ /**
13382
13480
  * Consumers that may DIAL. Every term of the former `hasDemand`, with the
13383
13481
  * RTSP session count filtered to exclude prestream dials, plus the wake hold.
13384
13482
  *
@@ -13538,6 +13636,31 @@ var StreamBroker = class StreamBroker {
13538
13636
  }, SUSPEND_GRACE_MS);
13539
13637
  }
13540
13638
  this.prestreamWanted = this.hasAnyConsumer() && !this.hasLiveIntent();
13639
+ this.reconcilePrestreamWindow();
13640
+ }
13641
+ /**
13642
+ * Open or close the prestream window to match {@link prestreamWanted}.
13643
+ *
13644
+ * This is the half that was missing. `prestreamWanted` has been computed
13645
+ * correctly since the feature shipped, but the only thing that ever STARTED
13646
+ * the pump was the lazy branch of `start()` — a DIAL, which needs live
13647
+ * intent, which a prestream session does not have by construction. So the
13648
+ * window opened only when something else had already woken the camera, and
13649
+ * `suspend()` closed it for good.
13650
+ *
13651
+ * Driving it from the consumer edge is what the contract already claimed:
13652
+ * the pump follows CONSUMERS, and it neither dials nor wakes nor holds
13653
+ * anything open (D173). `start()` is idempotent, so being called on every
13654
+ * demand check costs one encode per window and no more.
13655
+ */
13656
+ reconcilePrestreamWindow() {
13657
+ if (!this.prestreamPump) return;
13658
+ if (this.prestreamWanted && !this.prestreamWindowClosed) {
13659
+ const url = this.resolveCurrentSource()?.url ?? this.source?.url;
13660
+ if (url !== void 0) this.prestreamPump.start(url);
13661
+ return;
13662
+ }
13663
+ if (!this.prestreamWanted) this.prestreamPump.stop("no prestream consumer");
13541
13664
  }
13542
13665
  /**
13543
13666
  * Whether a prestream picture should be flowing right now: somebody is
@@ -13573,6 +13696,7 @@ var StreamBroker = class StreamBroker {
13573
13696
  }
13574
13697
  this.prestreamPump?.stop("broker suspended");
13575
13698
  this.prestreamWindowClosed = false;
13699
+ this.reconcilePrestreamWindow();
13576
13700
  this.preBuffer.clear();
13577
13701
  this.decoderSeedBuffer.clear();
13578
13702
  this.clipBuffer?.clear();
@@ -14580,15 +14704,31 @@ var StreamBroker = class StreamBroker {
14580
14704
  }
14581
14705
  /** Retain `seconds` of AnnexB video for notification clips (0 = stop, frees the ring). */
14582
14706
  setClipRetention(seconds) {
14583
- const clamped = Math.max(0, Math.min(seconds, StreamBroker.MAX_PRE_BUFFER_SEC));
14707
+ const clamped = Math.max(0, Math.min(seconds, StreamBroker.MAX_CLIP_RETENTION_SEC));
14584
14708
  if (clamped === this.clipRetentionSec) return;
14709
+ const previous = this.clipRetentionSec;
14585
14710
  this.clipRetentionSec = clamped;
14711
+ this.logger?.info(clamped === 0 ? "Clip retention off" : "Clip retention set", { meta: {
14712
+ seconds: clamped,
14713
+ requested: seconds,
14714
+ previousSeconds: previous
14715
+ } });
14586
14716
  if (clamped === 0) {
14587
14717
  this.clipBuffer = null;
14588
14718
  return;
14589
14719
  }
14590
14720
  if (this.clipBuffer) this.clipBuffer.setDuration(clamped);
14591
- else this.clipBuffer = new EncodedRingBuffer(clamped);
14721
+ else this.clipBuffer = new EncodedRingBuffer(clamped, StreamBroker.CLIP_RING_MAX_BYTES);
14722
+ }
14723
+ /**
14724
+ * Seconds this stream's clip ring is currently holding. `0` = no ring.
14725
+ *
14726
+ * A READ of what was applied, for the manager's own bound (how long a cut may
14727
+ * wait for its post-roll) and for the tests that pin the retention a camera's
14728
+ * notification rules ask for.
14729
+ */
14730
+ getClipRetentionSec() {
14731
+ return this.clipRetentionSec;
14592
14732
  }
14593
14733
  /**
14594
14734
  * The retained clip window, oldest first. Empty when retention is off.
@@ -16221,13 +16361,57 @@ function formatClockHHMM(at) {
16221
16361
  * reach `streaming` with audio detected, and the stats poll cadence. */
16222
16362
  var PROBE_STREAM_TIMEOUT_MS = 6e3;
16223
16363
  var PROBE_STREAM_POLL_MS = 250;
16224
- var CLIP_RETENTION_SEC = 25;
16364
+ /**
16365
+ * How much history a notification clip may be cut from on a camera the
16366
+ * notification centre has **not spoken about yet**.
16367
+ *
16368
+ * Retention is no longer flat: every camera holds what its own notification
16369
+ * rules ask for ({@link refreshClipRetentionPlan}), which is nothing at all on
16370
+ * a camera whose rules never cut footage. This number is what a camera holds
16371
+ * while that ask is UNKNOWN — before the first successful pull, and after a
16372
+ * pull that failed or answered with a rule set that has not loaded. Absence
16373
+ * must not read as zero: dropping a ring on boot, before the rules arrive,
16374
+ * loses the footage for exactly the first events after every restart, which is
16375
+ * when an operator is most likely to be watching. So an unknown camera keeps
16376
+ * what the whole fleet used to keep, and only an ANSWER can take it away.
16377
+ *
16378
+ * Sixty seconds, raised from 25 on 2026-09-15 after measuring what the
16379
+ * alternative sources can actually serve. The recordings' availability edge is
16380
+ * not a number but a PHASE: asking for the same 10-second window at different
16381
+ * anchors, full coverage arrived at −18 s on one camera and −26 s on two
16382
+ * others, depending on where the request falls in the 10-second segment cycle.
16383
+ * A notification window is `[anchor−4, anchor+6]`, so at 25 s the ring served
16384
+ * an anchor roughly 6–19 s old while the recordings began at 14–26 s — and on
16385
+ * an unlucky phase those bands did not meet, which is the silent "no clip" the
16386
+ * operator kept receiving. No fixed threshold closes a gap that opens and
16387
+ * closes with the segment cycle; margin over the WORST phase does.
16388
+ *
16389
+ * It is affordable only because this ring runs on the cheap profile: the
16390
+ * fleet's 22 cheap streams sum 4.3 Mbps, so a minute costs ~32 MB across every
16391
+ * camera — against ~800 MB for the same minute of the main stream. And it is
16392
+ * SAFE only because the ring is now bounded by bytes
16393
+ * (`StreamBroker.CLIP_RING_MAX_BYTES`): a rule that arms a clip on the main
16394
+ * stream is clamped to what 16 MiB holds rather than taking 82 MB.
16395
+ */
16396
+ var CLIP_RETENTION_UNKNOWN_SEC = 60;
16225
16397
  /** Cheapest-first — the profile a clip is cut from when the rule says nothing. */
16226
16398
  var CLIP_PROFILE_BY_COST = [
16227
16399
  "low",
16228
16400
  "mid",
16229
16401
  "high"
16230
16402
  ];
16403
+ /**
16404
+ * Head-room added to an OBSERVED ask (`preRoll + postRoll + this`).
16405
+ *
16406
+ * The notification centre derives the same shape from its own rules
16407
+ * (`NC_CLIP_DISPATCH_MARGIN_SEC`) and for the same reason: a cut is taken
16408
+ * seconds after the moment it is about — the row is queued, drained, and its
16409
+ * still is waited for — so a ring sized to the window alone drops the pre-roll
16410
+ * a heartbeat before the renderer asks for it. Deliberately NOT imported from
16411
+ * the notification addon: addons never import each other, and this is the
16412
+ * broker's own floor for a consumer that is not the notification centre.
16413
+ */
16414
+ var CLIP_OBSERVED_ASK_MARGIN_SEC = 10;
16231
16415
  /** How long `renderPreBufferClip` will wait for the post-roll to accumulate. */
16232
16416
  var CLIP_POST_ROLL_POLL_MS = 250;
16233
16417
  /**
@@ -18101,6 +18285,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18101
18285
  } else this.disabledDevices.delete(d.id);
18102
18286
  const live = cameras.filter((d) => !d.disabled);
18103
18287
  await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id, "poll")));
18288
+ await this.refreshClipRetentionPlan();
18104
18289
  await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
18105
18290
  const liveIds = new Set(live.map((d) => d.id));
18106
18291
  for (const deviceId of [...this.audioPlaneState.keys()]) if (!liveIds.has(deviceId)) this.audioPlaneState.delete(deviceId);
@@ -18639,8 +18824,8 @@ var StreamBrokerManager = class StreamBrokerManager {
18639
18824
  const assigned = CLIP_PROFILE_BY_COST.filter((p) => map[p] !== void 0);
18640
18825
  const wanted = pickClipProfile(assigned, input.profile);
18641
18826
  if (!wanted) throw new Error(`No profile assigned for device ${input.deviceId}`);
18642
- this.armClipRetention(input.deviceId, wanted);
18643
- const until = Math.min(input.aroundMs + input.postRollSec * 1e3, Date.now() + CLIP_RETENTION_SEC * 1e3);
18827
+ this.noteClipAsk(input.deviceId, wanted, input.preRollSec + input.postRollSec + CLIP_OBSERVED_ASK_MARGIN_SEC);
18828
+ const until = Math.min(input.aroundMs + input.postRollSec * 1e3, Date.now() + this.clipRetentionSecFor(input.deviceId) * 1e3);
18644
18829
  while (Date.now() < until) await new Promise((resolve) => setTimeout(resolve, CLIP_POST_ROLL_POLL_MS));
18645
18830
  const candidates = [wanted, ...assigned.filter((p) => p !== wanted)];
18646
18831
  let profile = null;
@@ -19178,7 +19363,8 @@ var StreamBrokerManager = class StreamBrokerManager {
19178
19363
  if (!sourceBroker) continue;
19179
19364
  const existingToken = this.persistedTokens.get(profileBrokerId);
19180
19365
  const warmUp = () => sourceBroker.onEncodedData(() => {});
19181
- this.rtspServer.registerRestreamer(profileBrokerId, sourceBroker.getRtspRestreamer(), warmUp, existingToken);
19366
+ const openPrestreamWindow = () => sourceBroker.openPrestreamWindow();
19367
+ this.rtspServer.registerRestreamer(profileBrokerId, sourceBroker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
19182
19368
  this.profileRestreamerBindings.set(profileBrokerId, sourceBrokerId);
19183
19369
  if (!existingToken) this.rtspProvider.persistTokens();
19184
19370
  }
@@ -20362,8 +20548,18 @@ var StreamBrokerManager = class StreamBrokerManager {
20362
20548
  this.applyClipRetention(deviceId);
20363
20549
  }
20364
20550
  /**
20365
- * Keep a clip window retained on the ONE profile clips are cut from — the
20366
- * cheapest assigned, which is what `pickClipProfile` defaults to.
20551
+ * Apply the retention THIS camera's notification rules actually ask for.
20552
+ *
20553
+ * Three inputs, one number:
20554
+ *
20555
+ * 1. the PLAN — `notificationRules.getClipRetentionAsks`, the rules' own
20556
+ * demand per camera ({@link refreshClipRetentionPlan});
20557
+ * 2. what a cut has been OBSERVED asking for on this camera
20558
+ * ({@link noteClipAsk}) — the old reactive arming, folded into the same
20559
+ * ask so there is one mechanism rather than two;
20560
+ * 3. nothing at all, when the plan has never arrived — see
20561
+ * {@link CLIP_RETENTION_UNKNOWN_SEC}. An unknown camera is not a camera
20562
+ * that needs nothing.
20367
20563
  *
20368
20564
  * Retention is opportunistic: it never dials a camera, it only fills while
20369
20565
  * something else already holds the stream up (usually the detection pipeline
@@ -20372,32 +20568,159 @@ var StreamBrokerManager = class StreamBrokerManager {
20372
20568
  */
20373
20569
  applyClipRetention(deviceId) {
20374
20570
  const map = this.assignments.get(deviceId)?.map ?? {};
20375
- const clipProfile = CLIP_PROFILE_BY_COST.find((p) => map[p] !== void 0);
20376
- const armed = this.clipRetentionArmed.get(deviceId);
20571
+ const resolved = this.resolveClipRetention(deviceId, map);
20377
20572
  for (const profile of CAM_PROFILE_ORDER) {
20378
20573
  const camStreamId = map[profile];
20379
20574
  if (!camStreamId) continue;
20380
- const wanted = profile === clipProfile || armed?.has(profile) === true;
20381
- this.brokers.get(brokerIdFor(deviceId, camStreamId))?.setClipRetention(wanted ? CLIP_RETENTION_SEC : 0);
20575
+ const wanted = resolved.profiles.has(profile) ? resolved.seconds : 0;
20576
+ this.brokers.get(brokerIdFor(deviceId, camStreamId))?.setClipRetention(wanted);
20577
+ }
20578
+ this.reportClipRetention(deviceId, resolved);
20579
+ }
20580
+ /**
20581
+ * The seconds and the profiles this camera's ring should hold, and WHY.
20582
+ *
20583
+ * The MAX over every ask that reaches the camera — the asks describe the
20584
+ * rules, the bounding belongs here (and, below this, to the ring's own byte
20585
+ * budget, which is never raised for an ask).
20586
+ */
20587
+ resolveClipRetention(deviceId, map) {
20588
+ const cheapest = CLIP_PROFILE_BY_COST.find((p) => map[p] !== void 0);
20589
+ const observed = this.clipRetentionObserved.get(deviceId);
20590
+ const profiles = /* @__PURE__ */ new Set();
20591
+ let seconds = 0;
20592
+ let source;
20593
+ if (this.clipRetentionAsks === null) {
20594
+ seconds = CLIP_RETENTION_UNKNOWN_SEC;
20595
+ source = "unknown";
20596
+ } else {
20597
+ source = "rules";
20598
+ for (const ask of this.clipRetentionAsks) {
20599
+ if (ask.deviceId !== void 0 && ask.deviceId !== deviceId) continue;
20600
+ if (ask.seconds > seconds) seconds = ask.seconds;
20601
+ if (ask.profile !== void 0) profiles.add(ask.profile);
20602
+ }
20603
+ }
20604
+ if (observed !== void 0) for (const [profile, asked] of observed) {
20605
+ if (asked > seconds) {
20606
+ seconds = asked;
20607
+ source = source === "unknown" ? "unknown" : "observed";
20608
+ }
20609
+ profiles.add(profile);
20382
20610
  }
20611
+ if (seconds <= 0) return {
20612
+ seconds: 0,
20613
+ profiles: /* @__PURE__ */ new Set(),
20614
+ source
20615
+ };
20616
+ if (cheapest !== void 0) profiles.add(cheapest);
20617
+ return {
20618
+ seconds,
20619
+ profiles,
20620
+ source
20621
+ };
20383
20622
  }
20384
20623
  /**
20385
- * Profiles a rule has actually asked a clip from, beyond the cheapest one
20386
- * retained by default.
20624
+ * Say it, once per transition, per camera.
20387
20625
  *
20388
- * This must be REMEMBERED, not just applied: catalog reconcile re-runs
20389
- * `applyClipRetention` on a timer, and without this the arming was wiped
20390
- * within the minute — a rule asking for `high` fell back to `low` forever
20391
- * and looked like the choice did nothing.
20626
+ * This mechanism MOVES — a rule created, disabled or deleted changes what a
20627
+ * camera holds within one reconcile tick — and a retention that changes
20628
+ * silently is a retention nobody can correlate with a missing clip. The
20629
+ * broker's own `setClipRetention` logs the buffer it allocated; this line
20630
+ * says who asked for it.
20631
+ */
20632
+ reportClipRetention(deviceId, resolved) {
20633
+ const signature = `${resolved.seconds}:${resolved.source}:${[...resolved.profiles].sort().join(",")}`;
20634
+ const previous = this.clipRetentionApplied.get(deviceId);
20635
+ if (previous === signature) return;
20636
+ this.clipRetentionApplied.set(deviceId, signature);
20637
+ this.logger.info(resolved.seconds === 0 ? "clip retention: none — no notification rule on this camera cuts footage" : "clip retention set from the notification rules", {
20638
+ tags: { deviceId },
20639
+ meta: {
20640
+ seconds: resolved.seconds,
20641
+ profiles: [...resolved.profiles],
20642
+ source: resolved.source,
20643
+ previous: previous ?? "none"
20644
+ }
20645
+ });
20646
+ }
20647
+ /** Seconds this camera's ring is currently asked to hold (0 when none). */
20648
+ clipRetentionSecFor(deviceId) {
20649
+ return this.resolveClipRetention(deviceId, this.assignments.get(deviceId)?.map ?? {}).seconds;
20650
+ }
20651
+ /**
20652
+ * PULL the notification centre's per-camera asks and apply them.
20653
+ *
20654
+ * A pull, not a push: a camera's ring lives on the node that ingests it while
20655
+ * `notification-rules` is a singleton, so a push would have to enumerate the
20656
+ * cluster. It also makes the failure mode right — a pull that fails, or one
20657
+ * answered by a centre whose rules have not loaded, changes NOTHING (D49: a
20658
+ * read that fails must never be able to destroy work, and dropping a ring is
20659
+ * destroying work). Only an answer about a rule set that was actually read
20660
+ * can take retention away.
20661
+ *
20662
+ * Rides the catalog reconcile, which is also the D8 backstop that re-derives
20663
+ * after a rule is created, edited, enabled, disabled or deleted — bounded
20664
+ * staleness (one tick), never a mirror that only follows the rules at boot.
20392
20665
  */
20666
+ async refreshClipRetentionPlan() {
20667
+ const api = this.api;
20668
+ if (!api) return;
20669
+ let plan;
20670
+ try {
20671
+ plan = await api.notificationRules.getClipRetentionAsks.query({});
20672
+ } catch (err) {
20673
+ this.logger.debug("clip retention plan unavailable — keeping the one in force", { meta: {
20674
+ error: errMsg(err),
20675
+ known: this.clipRetentionAsks !== null
20676
+ } });
20677
+ return;
20678
+ }
20679
+ if (!plan.rulesLoaded) {
20680
+ this.logger.debug("clip retention plan: the notification centre has not read its rules yet", { meta: { known: this.clipRetentionAsks !== null } });
20681
+ return;
20682
+ }
20683
+ this.clipRetentionAsks = plan.asks;
20684
+ for (const deviceId of this.assignments.keys()) this.applyClipRetention(deviceId);
20685
+ }
20393
20686
  /** Produced event artifacts waiting to be fetched by handle (D9/D18). */
20394
20687
  eventMedia = new EventMediaStore();
20395
- clipRetentionArmed = /* @__PURE__ */ new Map();
20396
- armClipRetention(deviceId, profile) {
20397
- const armed = this.clipRetentionArmed.get(deviceId) ?? /* @__PURE__ */ new Set();
20398
- if (armed.has(profile)) return;
20399
- armed.add(profile);
20400
- this.clipRetentionArmed.set(deviceId, armed);
20688
+ /**
20689
+ * The notification rules' own asks, per camera. `null` = never answered.
20690
+ *
20691
+ * `null` and `[]` are DIFFERENT and the difference is a camera's footage:
20692
+ * `[]` means the centre read its rules and none of them cut footage here,
20693
+ * `null` means nobody has said. Only the first one is allowed to zero a ring.
20694
+ */
20695
+ clipRetentionAsks = null;
20696
+ /**
20697
+ * What a cut was OBSERVED asking for, per camera and profile.
20698
+ *
20699
+ * The old `armClipRetention`, folded into the ask above so retention has ONE
20700
+ * input shape. It still exists because the plan describes the notification
20701
+ * rules and a cut can come from elsewhere (a clips API, an export, a rule
20702
+ * created a second ago on another node): a consumer that asked for a window
20703
+ * this ring did not hold gets it next time. It is REMEMBERED because the
20704
+ * reconcile re-applies retention on a timer, and without that the arming was
20705
+ * wiped within the minute — a rule asking for `high` fell back to `low`
20706
+ * forever and looked like the choice did nothing.
20707
+ */
20708
+ clipRetentionObserved = /* @__PURE__ */ new Map();
20709
+ /** Last reported (seconds, source, profiles) per camera — transitions only. */
20710
+ clipRetentionApplied = /* @__PURE__ */ new Map();
20711
+ /**
20712
+ * A cut asked this camera for `seconds` on `profile` — remember it.
20713
+ *
20714
+ * ONE ask, with both halves: the reactive arming used to carry the profile
20715
+ * and inherit the flat duration, so a consumer that needed a longer window
20716
+ * had no way to say so and a consumer that needed none still paid.
20717
+ */
20718
+ noteClipAsk(deviceId, profile, seconds) {
20719
+ const observed = this.clipRetentionObserved.get(deviceId) ?? /* @__PURE__ */ new Map();
20720
+ const bounded = Math.max(0, Math.min(seconds, CLIP_RETENTION_UNKNOWN_SEC));
20721
+ if ((observed.get(profile) ?? 0) >= bounded && this.clipRetentionObserved.has(deviceId)) return;
20722
+ observed.set(profile, Math.max(observed.get(profile) ?? 0, bounded));
20723
+ this.clipRetentionObserved.set(deviceId, observed);
20401
20724
  this.applyClipRetention(deviceId);
20402
20725
  }
20403
20726
  countPushConsumers(map, deviceId) {
@@ -20697,9 +21020,11 @@ var StreamBrokerManager = class StreamBrokerManager {
20697
21020
  return;
20698
21021
  }
20699
21022
  this.brokers.set(brokerId, broker);
21023
+ this.applyClipRetention(deviceId);
20700
21024
  const existingToken = this.persistedTokens.get(brokerId);
20701
21025
  const warmUp = () => broker.onEncodedData(() => {});
20702
- this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken);
21026
+ const openPrestreamWindow = () => broker.openPrestreamWindow();
21027
+ this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
20703
21028
  if (!existingToken) this.rtspProvider.persistTokens();
20704
21029
  await this.fanOutRegistration(deviceId, brokerId, cam, broker);
20705
21030
  this.syncProfileRestreamers(deviceId);
@@ -24273,6 +24598,18 @@ var AdaptiveSession = class AdaptiveSession {
24273
24598
  }
24274
24599
  static MAX_RTP_PAYLOAD = 1100;
24275
24600
  rtpPacketsSent = 0;
24601
+ /**
24602
+ * Cumulative RTP packets this session has put on the wire.
24603
+ *
24604
+ * Read by the adaptive ladder's stall detector: "the client received
24605
+ * nothing" only means the link is broken if the broker was SENDING at the
24606
+ * time. Without this half, a paused camera, a battery source between wakes
24607
+ * or any legitimately idle feed reads as a stall and walks the ladder to
24608
+ * the floor for nothing.
24609
+ */
24610
+ get rtpPacketsSentTotal() {
24611
+ return this.rtpPacketsSent;
24612
+ }
24276
24613
  rtpLogCounter = 0;
24277
24614
  /** One-shot guards for the "first video/audio RTP" debug markers. */
24278
24615
  firstVideoRtpLogged = false;
@@ -25060,6 +25397,105 @@ var LossTracker = class {
25060
25397
  }
25061
25398
  };
25062
25399
  //#endregion
25400
+ //#region src/stream-broker/webrtc/stall-tracker.ts
25401
+ /**
25402
+ * Windowed "the media stopped arriving" detector. Pure state machine: no
25403
+ * clock of its own, no side effects — `observe` is the only mutator and every
25404
+ * decision is a function of the samples it was given.
25405
+ */
25406
+ var StallTracker = class {
25407
+ config;
25408
+ /** When the current uninterrupted stall began; null when not stalling. */
25409
+ stallStartedAt = null;
25410
+ blackoutUntil = 0;
25411
+ lastEvidence = {
25412
+ clientKbps: null,
25413
+ stalledForMs: 0,
25414
+ serverSending: false
25415
+ };
25416
+ constructor(config) {
25417
+ this.config = config;
25418
+ }
25419
+ /** A tier change just happened — ignore the re-offer's own dead air. */
25420
+ markChanged(now) {
25421
+ this.stallStartedAt = null;
25422
+ this.blackoutUntil = now + this.config.waitAfterChangeMs;
25423
+ }
25424
+ /**
25425
+ * Fold one evaluation into the window. Returns nothing; ask `isStalled`.
25426
+ *
25427
+ * Three outcomes, and the middle one is the reason this is not a simple
25428
+ * boolean: a rate at or above `recoverKbpsFloor` CLEARS the stall, a rate
25429
+ * below `stallKbpsFloor` starts or extends it, and anything between the two
25430
+ * (or unknown, or with the server idle) HOLDS whatever the window already
25431
+ * believed. Holding is what stops a borderline link from oscillating.
25432
+ */
25433
+ observe(sample, now) {
25434
+ if (now < this.blackoutUntil) {
25435
+ this.stallStartedAt = null;
25436
+ this.lastEvidence = {
25437
+ clientKbps: sample.clientKbps,
25438
+ stalledForMs: 0,
25439
+ serverSending: sample.serverSending
25440
+ };
25441
+ return;
25442
+ }
25443
+ const kbps = sample.clientKbps;
25444
+ const age = sample.clientSampleAgeMs;
25445
+ if (!(kbps !== null && Number.isFinite(kbps) && age !== null && age <= this.config.maxClientSampleAgeMs) || kbps === null) {
25446
+ this.lastEvidence = {
25447
+ clientKbps: null,
25448
+ stalledForMs: this.stalledForMs(now),
25449
+ serverSending: sample.serverSending
25450
+ };
25451
+ return;
25452
+ }
25453
+ if (kbps >= this.config.recoverKbpsFloor) this.stallStartedAt = null;
25454
+ else if (kbps < this.config.stallKbpsFloor && sample.serverSending) this.stallStartedAt ??= now;
25455
+ this.lastEvidence = {
25456
+ clientKbps: kbps,
25457
+ stalledForMs: this.stalledForMs(now),
25458
+ serverSending: sample.serverSending
25459
+ };
25460
+ }
25461
+ stalledForMs(now) {
25462
+ return this.stallStartedAt === null ? 0 : now - this.stallStartedAt;
25463
+ }
25464
+ /** True once the stall has persisted for `stallConfirmMs`. */
25465
+ isStalled(now) {
25466
+ if (now < this.blackoutUntil) return false;
25467
+ return this.stalledForMs(now) >= this.config.stallConfirmMs;
25468
+ }
25469
+ /**
25470
+ * May the ladder PROMOTE on this evaluation?
25471
+ *
25472
+ * False when the last fresh measurement was below `recoverKbpsFloor` —
25473
+ * either a stall building, or a link crawling along in the dead band.
25474
+ * Promoting there spends bandwidth the session has just been shown not to
25475
+ * have, and the loss path cannot object: a stream delivering almost nothing
25476
+ * reports almost no loss. Same doctrine as `LossTracker.hasEvidence` — a
25477
+ * climb needs POSITIVE evidence, not the absence of a complaint.
25478
+ *
25479
+ * Unknown does not block. A deployment with no client telemetry at all must
25480
+ * behave exactly as it did before this tracker existed; the loss path's own
25481
+ * `hasEvidence` is the gate there.
25482
+ */
25483
+ isClimbSafe(now) {
25484
+ if (now < this.blackoutUntil) return true;
25485
+ if (this.stallStartedAt !== null) return false;
25486
+ const last = this.lastEvidence.clientKbps;
25487
+ if (last === null) return true;
25488
+ return last >= this.config.recoverKbpsFloor;
25489
+ }
25490
+ /** What the last `observe` measured — for the switch's log line. */
25491
+ evidence(now) {
25492
+ return {
25493
+ ...this.lastEvidence,
25494
+ stalledForMs: this.stalledForMs(now)
25495
+ };
25496
+ }
25497
+ };
25498
+ //#endregion
25063
25499
  //#region src/stream-broker/webrtc/adaptive-controller.ts
25064
25500
  var TIER_ORDER = CAM_PROFILE_ORDER;
25065
25501
  /**
@@ -25081,6 +25517,7 @@ var TIER_ORDER = CAM_PROFILE_ORDER;
25081
25517
  var AdaptiveController = class {
25082
25518
  opts;
25083
25519
  tracker;
25520
+ stalls;
25084
25521
  tier;
25085
25522
  mtuReduced = false;
25086
25523
  transcodeEngaged = false;
@@ -25099,6 +25536,7 @@ var AdaptiveController = class {
25099
25536
  constructor(opts) {
25100
25537
  this.opts = opts;
25101
25538
  this.tracker = new LossTracker(opts.config);
25539
+ this.stalls = new StallTracker(opts.config);
25102
25540
  this.tier = opts.initialTier;
25103
25541
  this.failures = new Map(opts.priorFailures ?? []);
25104
25542
  this.upgradeCount = Math.max(0, opts.priorUpgradeCount ?? 0);
@@ -25121,6 +25559,19 @@ var AdaptiveController = class {
25121
25559
  this.tracker.track(sample, now);
25122
25560
  }
25123
25561
  /**
25562
+ * Fold one TRANSPORT measurement into the ladder — what the client actually
25563
+ * received, and whether the broker was sending at the time.
25564
+ *
25565
+ * This is the half the ladder was missing. Packet loss cannot see a freeze
25566
+ * (a stream delivering nothing reports 0 % loss, because there are no gaps
25567
+ * in a sequence that received no packets), and the client's downlink hint
25568
+ * cannot see one either — it is a one-shot HTTP burst measured at connect
25569
+ * time, on a path the media does not take. See `stall-tracker.ts`.
25570
+ */
25571
+ onThroughput(sample, now) {
25572
+ this.stalls.observe(sample, now);
25573
+ }
25574
+ /**
25124
25575
  * Freeze the ladder for this evaluation instead of ticking it.
25125
25576
  *
25126
25577
  * Called while the session's track is NOT carrying the live source — D42
@@ -25141,6 +25592,7 @@ var AdaptiveController = class {
25141
25592
  this.lastChangeAt = now;
25142
25593
  }
25143
25594
  this.healthySince = now;
25595
+ this.stalls.markChanged(now);
25144
25596
  }
25145
25597
  tick(now) {
25146
25598
  if (!this.armed) {
@@ -25148,9 +25600,18 @@ var AdaptiveController = class {
25148
25600
  this.healthySince = now;
25149
25601
  this.lastChangeAt = now;
25150
25602
  }
25603
+ if (this.stalls.isStalled(now)) {
25604
+ this.healthySince = now;
25605
+ this.handleFailing(now, "stall");
25606
+ return;
25607
+ }
25151
25608
  if (this.tracker.isFailing(now)) {
25152
25609
  this.healthySince = now;
25153
- this.handleFailing(now);
25610
+ this.handleFailing(now, "loss");
25611
+ return;
25612
+ }
25613
+ if (!this.stalls.isClimbSafe(now)) {
25614
+ this.healthySince = now;
25154
25615
  return;
25155
25616
  }
25156
25617
  if (!this.tracker.hasEvidence(now)) {
@@ -25176,26 +25637,36 @@ var AdaptiveController = class {
25176
25637
  commitChange(now) {
25177
25638
  this.lastChangeAt = now;
25178
25639
  this.tracker.markChanged(now);
25640
+ this.stalls.markChanged(now);
25179
25641
  }
25180
- handleFailing(now) {
25642
+ /** The measurement this decision rests on, snapshotted for the log line. */
25643
+ decisionAt(now, cause) {
25644
+ return {
25645
+ cause,
25646
+ lossRatio: this.tracker.lossRatio(),
25647
+ stall: this.stalls.evidence(now)
25648
+ };
25649
+ }
25650
+ handleFailing(now, cause) {
25651
+ const decision = this.decisionAt(now, cause);
25181
25652
  const idx = TIER_ORDER.indexOf(this.tier);
25182
25653
  if (idx < TIER_ORDER.length - 1) {
25183
25654
  const failed = this.tier;
25184
25655
  this.failures.set(failed, (this.failures.get(failed) ?? 0) + 1);
25185
25656
  this.tier = TIER_ORDER[idx + 1];
25186
- this.opts.onSwitchTier(this.tier);
25657
+ this.opts.onSwitchTier(this.tier, decision);
25187
25658
  this.commitChange(now);
25188
25659
  return;
25189
25660
  }
25190
25661
  if (!this.mtuReduced) {
25191
25662
  this.mtuReduced = true;
25192
- this.opts.onReduceMtu();
25663
+ this.opts.onReduceMtu(decision);
25193
25664
  this.commitChange(now);
25194
25665
  return;
25195
25666
  }
25196
25667
  if (!this.transcodeEngaged) {
25197
25668
  this.transcodeEngaged = true;
25198
- this.opts.onTranscodeDown();
25669
+ this.opts.onTranscodeDown(decision);
25199
25670
  this.commitChange(now);
25200
25671
  }
25201
25672
  }
@@ -25209,7 +25680,7 @@ var AdaptiveController = class {
25209
25680
  this.mtuReduced = false;
25210
25681
  this.transcodeEngaged = false;
25211
25682
  this.upgradeCount += 1;
25212
- this.opts.onSwitchTier(next);
25683
+ this.opts.onSwitchTier(next, this.decisionAt(now, "healthy"));
25213
25684
  this.commitChange(now);
25214
25685
  this.healthySince = now;
25215
25686
  }
@@ -29350,7 +29821,12 @@ var ADAPTIVE_CONFIG = {
29350
29821
  upgradeHealthyGrowthFactor: 3,
29351
29822
  maxProgressiveUpgradeHealthyMs: 3e4,
29352
29823
  minTimeBetweenChangesMs: 4e3,
29353
- maxUpgradeBackoffMs: 9e5
29824
+ maxUpgradeBackoffMs: 9e5,
29825
+ stallKbpsFloor: 64,
29826
+ stallConfirmMs: 6e3,
29827
+ recoverKbpsFloor: 128,
29828
+ waitAfterChangeMs: 8e3,
29829
+ maxClientSampleAgeMs: 1e4
29354
29830
  };
29355
29831
  /**
29356
29832
  * True when `broker` is an in-process StreamBroker exposing the Opus egress
@@ -29494,7 +29970,7 @@ var BrokerWebrtcServer = class {
29494
29970
  staticIceServers;
29495
29971
  icePortRange;
29496
29972
  iceAdditionalHostAddresses;
29497
- clientLossPct;
29973
+ clientNetworkStats;
29498
29974
  locateRecordedSegment;
29499
29975
  readRecordedSegmentBytes;
29500
29976
  readRecordedGopBytes;
@@ -29561,7 +30037,7 @@ var BrokerWebrtcServer = class {
29561
30037
  this.staticIceServers = options.iceServers;
29562
30038
  this.icePortRange = options.icePortRange;
29563
30039
  this.iceAdditionalHostAddresses = options.iceAdditionalHostAddresses;
29564
- this.clientLossPct = options.clientLossPct;
30040
+ this.clientNetworkStats = options.clientNetworkStats;
29565
30041
  this.locateRecordedSegment = options.locateRecordedSegment;
29566
30042
  this.readRecordedSegmentBytes = options.readRecordedSegmentBytes;
29567
30043
  this.readRecordedGopBytes = options.readRecordedGopBytes;
@@ -30499,12 +30975,27 @@ var BrokerWebrtcServer = class {
30499
30975
  priorFailures,
30500
30976
  priorUpgradeCount,
30501
30977
  config: ADAPTIVE_CONFIG,
30502
- onSwitchTier: (tier) => {
30503
- this.switchSessionTier(entry, deviceId, tier, sessionId);
30978
+ onSwitchTier: (tier, decision) => {
30979
+ this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
30504
30980
  },
30505
- onReduceMtu: () => entry.session.setRepacketizerMtu(AdaptiveSession.ADAPTIVE_LOW_MTU),
30506
- onTranscodeDown: () => {
30507
- this.switchSessionTier(entry, deviceId, "low", sessionId, true);
30981
+ onReduceMtu: (decision) => {
30982
+ entry.session.setRepacketizerMtu(AdaptiveSession.ADAPTIVE_LOW_MTU);
30983
+ this.logger.info("adaptive: reducing repacketizer MTU", {
30984
+ tags: { deviceId },
30985
+ meta: {
30986
+ sessionId,
30987
+ deviceId,
30988
+ mtu: AdaptiveSession.ADAPTIVE_LOW_MTU,
30989
+ reason: decision.cause,
30990
+ lossRatio: Number(decision.lossRatio.toFixed(4)),
30991
+ clientKbps: decision.stall.clientKbps,
30992
+ stalledForMs: decision.stall.stalledForMs,
30993
+ serverSending: decision.stall.serverSending
30994
+ }
30995
+ });
30996
+ },
30997
+ onTranscodeDown: (decision) => {
30998
+ this.switchSessionTier(entry, deviceId, "low", sessionId, true, decision);
30508
30999
  }
30509
31000
  });
30510
31001
  entry.session.onReceiverReport((s) => controller.onLoss(s, Date.now()));
@@ -30512,6 +31003,9 @@ var BrokerWebrtcServer = class {
30512
31003
  let clientHighestSequence = 0;
30513
31004
  let clientPacketsLost = 0;
30514
31005
  let tickInFlight = false;
31006
+ /** RTP packets sent as of the previous evaluation — the "was the broker
31007
+ * sending?" half of the stall verdict. */
31008
+ let lastRtpSent = entry.session.rtpPacketsSentTotal;
30515
31009
  const CLIENT_LOSS_SPAN = 1e3;
30516
31010
  entry.adaptiveTimer = setInterval(() => {
30517
31011
  if (tickInFlight) return;
@@ -30520,10 +31014,20 @@ var BrokerWebrtcServer = class {
30520
31014
  try {
30521
31015
  if (entry.timeline?.currentMode === "recorded") {
30522
31016
  controller.suspend(Date.now());
31017
+ lastRtpSent = entry.session.rtpPacketsSentTotal;
30523
31018
  return;
30524
31019
  }
30525
- const pct = this.clientLossPct ? await this.clientLossPct(deviceId) : null;
31020
+ const client = this.clientNetworkStats ? await this.clientNetworkStats(deviceId) : null;
30526
31021
  const now = Date.now();
31022
+ const rtpSent = entry.session.rtpPacketsSentTotal;
31023
+ const serverSending = rtpSent > lastRtpSent;
31024
+ lastRtpSent = rtpSent;
31025
+ controller.onThroughput({
31026
+ clientKbps: client ? client.estimatedBandwidthKbps : null,
31027
+ clientSampleAgeMs: client ? Math.max(0, now - client.lastUpdated) : null,
31028
+ serverSending
31029
+ }, now);
31030
+ const pct = client?.packetLossPercent ?? null;
30527
31031
  if (pct !== null && pct !== void 0) {
30528
31032
  clientHighestSequence += CLIENT_LOSS_SPAN;
30529
31033
  clientPacketsLost += Math.round(Math.max(0, Math.min(100, pct)) / 100 * CLIENT_LOSS_SPAN);
@@ -30552,7 +31056,7 @@ var BrokerWebrtcServer = class {
30552
31056
  * consumes the recorded intent to pin the recreated session to `tier` and
30553
31057
  * apply the reduced transcode profile when `forceTranscodeDown` is set.
30554
31058
  */
30555
- switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false) {
31059
+ switchSessionTier(entry, deviceId, tier, sessionId, forceTranscodeDown = false, decision) {
30556
31060
  const prev = this.adaptiveIntent.get(sessionId);
30557
31061
  const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
30558
31062
  const upgradeCount = entry.adaptiveController?.successfulUpgradeCount ?? prev?.upgradeCount ?? 0;
@@ -30576,7 +31080,12 @@ var BrokerWebrtcServer = class {
30576
31080
  forceTranscodeDown,
30577
31081
  epoch,
30578
31082
  failures: Object.fromEntries(failures),
30579
- upgradeCount
31083
+ upgradeCount,
31084
+ reason: decision?.cause ?? "unknown",
31085
+ lossRatio: decision ? Number(decision.lossRatio.toFixed(4)) : null,
31086
+ clientKbps: decision?.stall.clientKbps ?? null,
31087
+ stalledForMs: decision?.stall.stalledForMs ?? null,
31088
+ serverSending: decision?.stall.serverSending ?? null
30580
31089
  }
30581
31090
  });
30582
31091
  }
@@ -31648,11 +32157,17 @@ var StreamBrokerAddon = class extends BaseAddon {
31648
32157
  return [];
31649
32158
  }
31650
32159
  },
31651
- clientLossPct: async (deviceId) => {
32160
+ clientNetworkStats: async (deviceId) => {
31652
32161
  const ctrl = new AbortController();
31653
32162
  const timer = setTimeout(() => ctrl.abort(), NETWORK_QUALITY_STATS_TIMEOUT_MS);
31654
32163
  try {
31655
- return (await this.ctx.api.networkQuality.getDeviceStats.query({ deviceId }, { signal: ctrl.signal }))?.client?.packetLossPercent ?? null;
32164
+ const client = (await this.ctx.api.networkQuality.getDeviceStats.query({ deviceId }, { signal: ctrl.signal }))?.client;
32165
+ if (!client) return null;
32166
+ return {
32167
+ packetLossPercent: client.packetLossPercent,
32168
+ estimatedBandwidthKbps: client.estimatedBandwidthKbps,
32169
+ lastUpdated: client.lastUpdated
32170
+ };
31656
32171
  } catch {
31657
32172
  return null;
31658
32173
  } finally {