@camstack/addon-pipeline 1.1.39 → 1.1.40

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 (38) hide show
  1. package/dist/audio-analyzer/index.js +168 -61
  2. package/dist/audio-analyzer/index.mjs +168 -61
  3. package/dist/detection-pipeline/index.js +2 -2
  4. package/dist/detection-pipeline/index.mjs +2 -2
  5. package/dist/{dist-DbGdZ8Nr.js → dist-BJTPkJFw.js} +247 -3
  6. package/dist/{dist-DvvYzO58.mjs → dist-v6cKLmU3.mjs} +247 -3
  7. package/dist/{frame-handle-plane-B7jMIZc2.js → frame-handle-plane-BP8YV4sF.js} +54 -5
  8. package/dist/{frame-handle-plane-BFzoIfkd.mjs → frame-handle-plane-DKAXTtfn.mjs} +54 -5
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +893 -12
  12. package/dist/pipeline-runner/index.mjs +892 -13
  13. package/dist/recorder/index.js +2 -2
  14. package/dist/recorder/index.mjs +2 -2
  15. package/dist/session-decode/decode-worker-child.js +511 -0
  16. package/dist/session-decode/decode-worker-child.mjs +510 -0
  17. package/dist/stream-broker/_stub.js +44 -44
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DezFy4Fu.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-6I7-nufj.mjs} +3 -3
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqJvWBKS.mjs +26 -0
  20. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-dGka8yZY.mjs +26 -0
  21. package/dist/stream-broker/{hostInit-BOf0hg9q.mjs → hostInit-CUkFGqiI.mjs} +3 -3
  22. package/dist/stream-broker/index.js +268 -38
  23. package/dist/stream-broker/index.mjs +268 -38
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/worker-protocol-BCfO8gUF.js +67 -0
  26. package/dist/worker-protocol-pk7qdYXt.mjs +56 -0
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-Br2fqwe7.js → MaskShapeCanvas-DI4BY7W2-Cr_fCzY5.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-DYXRf30g.js → MotionZonesSettings-NcxxQN8r-COcvoHDH.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DLpqLgPo.js → PrivacyMaskSettings-APgPLF7p-Bv-bbyJl.js} +1 -1
  30. package/embed-dist/assets/index-Ctbq9AXh.css +2 -0
  31. package/embed-dist/assets/index-jMj5PyJk.js +81 -0
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +6 -3
  34. package/python/inference_pool.py +3 -1
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DhY3MZ2C.mjs +0 -26
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BsrrdOBU.mjs +0 -26
  37. package/embed-dist/assets/index-C5UpuPr8.css +0 -2
  38. package/embed-dist/assets/index-CR367rYR.js +0 -81
@@ -3,20 +3,20 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-DxM2DSns.js");
6
- const require_dist = require("../dist-DbGdZ8Nr.js");
7
- const require_frame_handle_plane = require("../frame-handle-plane-B7jMIZc2.js");
6
+ const require_dist = require("../dist-BJTPkJFw.js");
7
+ const require_frame_handle_plane = require("../frame-handle-plane-BP8YV4sF.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  node_crypto = require_model_download_service_C_IHWnXx.__toESM(node_crypto);
10
+ let node_child_process = require("node:child_process");
11
+ let node_url = require("node:url");
10
12
  let node_fs = require("node:fs");
11
13
  node_fs = require_model_download_service_C_IHWnXx.__toESM(node_fs);
12
14
  let node_path = require("node:path");
13
15
  node_path = require_model_download_service_C_IHWnXx.__toESM(node_path);
14
16
  let node_os = require("node:os");
15
17
  node_os = require_model_download_service_C_IHWnXx.__toESM(node_os);
16
- let node_child_process = require("node:child_process");
17
18
  let node_dns_promises = require("node:dns/promises");
18
19
  let node_module = require("node:module");
19
- let node_url = require("node:url");
20
20
  let node_net = require("node:net");
21
21
  node_net = require_model_download_service_C_IHWnXx.__toESM(node_net);
22
22
  let net = require("net");
@@ -4752,6 +4752,14 @@ var RtspSession = class {
4752
4752
  userAgent = null;
4753
4753
  /** When true, this session receives video-only (no audio RTP). */
4754
4754
  muted;
4755
+ /**
4756
+ * Latch for a `muted` (local-decoder loopback) session that has started
4757
+ * dropping video packets on socket backlog. While set, every non-keyframe
4758
+ * packet is dropped — even after the backlog clears — until the next keyframe
4759
+ * access unit, so the decoder always resumes on a clean GOP boundary instead
4760
+ * of mid-GOP with reference-broken (missing P-frame) packets.
4761
+ */
4762
+ droppingUntilKeyframe = false;
4755
4763
  constructor(socket, sdp, onClose, muted) {
4756
4764
  this.socket = socket;
4757
4765
  this.sdp = sdp;
@@ -4801,13 +4809,35 @@ var RtspSession = class {
4801
4809
  bytesSent: this.bytesSent
4802
4810
  };
4803
4811
  }
4804
- /** Send a video RTP packet via TCP interleaved framing. */
4805
- sendRtp(packet) {
4812
+ /**
4813
+ * Send a video RTP packet via TCP interleaved framing.
4814
+ *
4815
+ * `prime` marks a packet from the current-GOP catch-up burst replayed on
4816
+ * join — written unconditionally (bounded one-shot the local decoder drains
4817
+ * fast; dropping mid-burst would break frame references). A prime burst
4818
+ * starts at a keyframe by construction, so it also clears the
4819
+ * `droppingUntilKeyframe` latch.
4820
+ *
4821
+ * `isKeyframe` marks a packet that begins a keyframe access unit — the clean
4822
+ * GOP boundary at which a latched `muted` session resumes sending.
4823
+ */
4824
+ sendRtp(packet, prime = false, isKeyframe = false) {
4806
4825
  if (!this.playing || this.closed) return;
4807
4826
  if (this.tracks.length === 0) return;
4808
- if (this.socket.writableLength > 2097152) {
4809
- this.close();
4810
- return;
4827
+ if (prime) this.droppingUntilKeyframe = false;
4828
+ else {
4829
+ if (this.droppingUntilKeyframe) {
4830
+ if (!isKeyframe) return;
4831
+ this.droppingUntilKeyframe = false;
4832
+ }
4833
+ if (this.socket.writableLength > 2097152) {
4834
+ if (this.muted) {
4835
+ this.droppingUntilKeyframe = true;
4836
+ return;
4837
+ }
4838
+ this.close();
4839
+ return;
4840
+ }
4811
4841
  }
4812
4842
  const channel = this.tracks[0].rtpChannel;
4813
4843
  const frame = Buffer.allocUnsafe(4 + packet.data.length);
@@ -5532,6 +5562,16 @@ function appendAudioMediaBlock(videoSdp, audioBlock) {
5532
5562
  //#endregion
5533
5563
  //#region src/stream-broker/rtsp/rtsp-restreamer.ts
5534
5564
  /**
5565
+ * Max age (ms) of a prime burst a MUTED (local-decoder loopback) session will
5566
+ * accept. A muted decoder must anchor at the LIVE edge — a full-GOP catch-up
5567
+ * burst (2-3s old) makes libav base its pts 2-3s in the past and, with ~zero
5568
+ * decode headroom, it never drains that backlog. So a muted session only joins
5569
+ * off a burst already near live (span ≤ this); otherwise it is left pending to
5570
+ * begin at the next live keyframe. Non-muted sessions (viewer / recorder) still
5571
+ * get the full-GOP burst for an instant picture.
5572
+ */
5573
+ var LIVE_EDGE_MAX_SPAN_MS = 500;
5574
+ /**
5535
5575
  * Per-broker RTSP restreamer.
5536
5576
  *
5537
5577
  * Operates in two modes:
@@ -5577,9 +5617,22 @@ var RtspRestreamer = class {
5577
5617
  collectingKeyframeRtp = false;
5578
5618
  logger = null;
5579
5619
  _onSessionCountChanged = null;
5620
+ /**
5621
+ * Supplies the broker's current-GOP source-RTP snapshot (keyframe..now) so a
5622
+ * joining decoder is primed with the WHOLE recent GOP as a burst and
5623
+ * fast-forwards to the live edge — instead of a lone keyframe up to a GOP old
5624
+ * followed by reference-broken live packets. Mirrors Scrypted's
5625
+ * prebuffer/findSyncFrame. Returns [] until a keyframe is seen (falls back to
5626
+ * the single cached keyframe AU).
5627
+ */
5628
+ rtpPreBufferSupplier = null;
5580
5629
  constructor(streamName) {
5581
5630
  this.streamName = streamName;
5582
5631
  }
5632
+ /** Wire the broker's current-GOP RTP snapshot supplier (see field doc). */
5633
+ setRtpPreBufferSupplier(supplier) {
5634
+ this.rtpPreBufferSupplier = supplier;
5635
+ }
5583
5636
  /** True when using RTP passthrough (native client) instead of Annex-B re-packetization. */
5584
5637
  get rtpPassthroughMode() {
5585
5638
  return this._rtpPassthroughMode;
@@ -5665,9 +5718,8 @@ var RtspRestreamer = class {
5665
5718
  total: this.sessions.size
5666
5719
  } });
5667
5720
  this._onSessionCountChanged?.();
5668
- if (this._rtpPassthroughMode) {
5669
- if (this.lastKeyframeRtpPackets.length > 0) setTimeout(() => this.servePendingSessionsRtp(), 0);
5670
- } else if (this.lastKeyframeNals.length > 0 && this.packetizer) setTimeout(() => this.servePendingSessions(), 0);
5721
+ if (this._rtpPassthroughMode) setTimeout(() => this.servePendingSessionsRtp(), 0);
5722
+ else if (this.lastKeyframeNals.length > 0 && this.packetizer) setTimeout(() => this.servePendingSessions(), 0);
5671
5723
  }
5672
5724
  /**
5673
5725
  * Set the camera's SDP directly (from DESCRIBE response).
@@ -5699,6 +5751,7 @@ var RtspRestreamer = class {
5699
5751
  data: rtpData,
5700
5752
  marker: (rtpData[1] & 128) !== 0
5701
5753
  };
5754
+ const keyframeAuStart = (isKeyframe || isParameterSet) && !this.collectingKeyframeRtp;
5702
5755
  if (isKeyframe || isParameterSet) {
5703
5756
  if (!this.collectingKeyframeRtp) {
5704
5757
  this.collectingKeyframeRtp = true;
@@ -5714,23 +5767,49 @@ var RtspRestreamer = class {
5714
5767
  }
5715
5768
  if (this.sessions.size === 0) return;
5716
5769
  this.servePendingSessionsRtp();
5770
+ if (keyframeAuStart) for (const session of this.sessions.values()) {
5771
+ if (!session.isMuted() || !session.isPlaying()) continue;
5772
+ this.pendingKeyframe.delete(session.getSessionId());
5773
+ }
5717
5774
  for (const session of this.sessions.values()) {
5718
5775
  if (this.pendingKeyframe.has(session.getSessionId())) continue;
5719
- if (session.isPlaying()) session.sendRtp(rtpPacket);
5776
+ if (session.isPlaying()) session.sendRtp(rtpPacket, false, isKeyframe || isParameterSet);
5720
5777
  }
5721
5778
  }
5722
5779
  /** Send cached keyframe RTP packets to sessions that transitioned to PLAY. */
5723
5780
  servePendingSessionsRtp() {
5724
5781
  if (this.pendingKeyframe.size === 0) return;
5725
- if (this.lastKeyframeRtpPackets.length === 0) return;
5782
+ const ringPackets = this.rtpPreBufferSupplier?.() ?? [];
5783
+ const fromRing = ringPackets.length > 0;
5784
+ const primePackets = fromRing ? ringPackets.map((data) => ({
5785
+ data,
5786
+ marker: (data[1] & 128) !== 0
5787
+ })) : this.lastKeyframeRtpPackets;
5788
+ if (primePackets.length === 0) return;
5789
+ const firstTs = primePackets[0].data.readUInt32BE(4);
5790
+ const spanMs = (primePackets[primePackets.length - 1].data.readUInt32BE(4) - firstTs | 0) / 90;
5726
5791
  const ready = [];
5727
5792
  for (const sessionId of this.pendingKeyframe) if (this.sessions.get(sessionId)?.isPlaying()) ready.push(sessionId);
5728
5793
  if (ready.length === 0) return;
5729
- for (const rtpPacket of this.lastKeyframeRtpPackets) for (const sessionId of ready) {
5794
+ const served = [];
5795
+ for (const sessionId of ready) {
5796
+ const session = this.sessions.get(sessionId);
5797
+ if (!session) continue;
5798
+ if (session.isMuted() && (!fromRing || spanMs > LIVE_EDGE_MAX_SPAN_MS)) continue;
5799
+ served.push(sessionId);
5800
+ }
5801
+ if (served.length === 0) return;
5802
+ this.logger?.info("restream prime (lat-probe)", { meta: {
5803
+ source: fromRing ? "gop-burst" : "keyframe-only",
5804
+ packets: primePackets.length,
5805
+ spanMs: Math.round(spanMs),
5806
+ clients: served.length
5807
+ } });
5808
+ for (const rtpPacket of primePackets) for (const sessionId of served) {
5730
5809
  const session = this.sessions.get(sessionId);
5731
- if (session?.isPlaying()) session.sendRtp(rtpPacket);
5810
+ if (session?.isPlaying()) session.sendRtp(rtpPacket, true);
5732
5811
  }
5733
- for (const id of ready) this.pendingKeyframe.delete(id);
5812
+ for (const id of served) this.pendingKeyframe.delete(id);
5734
5813
  }
5735
5814
  /**
5736
5815
  * Push a raw audio RTP packet from the native RTSP client.
@@ -8640,6 +8719,38 @@ var AudioCodecSession = class {
8640
8719
  sessionId: this.sessionId
8641
8720
  };
8642
8721
  }
8722
+ /**
8723
+ * Tear down the upstream decode session + 40ms poll loop WITHOUT
8724
+ * permanently closing this handle. The broker calls this from its
8725
+ * audio-consumer demand-gate: when audio consumers drop to zero the
8726
+ * upstream decoder session and its poll loop are pure waste, so we
8727
+ * release them. Unlike {@link close} this leaves the handle reusable —
8728
+ * the next pushed packet re-creates the session lazily via
8729
+ * `ensureSession()`. Safe to call repeatedly.
8730
+ */
8731
+ async idle() {
8732
+ if (this.closed) return;
8733
+ if (this.creating) await this.creating;
8734
+ if (this.closed) return;
8735
+ if (this.pollTimer) {
8736
+ clearInterval(this.pollTimer);
8737
+ this.pollTimer = null;
8738
+ }
8739
+ if (!this.sessionId) return;
8740
+ const sid = this.sessionId;
8741
+ const nodeId = this.sessionNodeId ?? void 0;
8742
+ this.sessionId = null;
8743
+ this.sessionNodeId = null;
8744
+ try {
8745
+ await this.api.closeSession({
8746
+ sessionId: sid,
8747
+ ...nodeId ? { nodeId } : {}
8748
+ });
8749
+ } catch (err) {
8750
+ this.logger?.warn("audio-codec: idle closeSession failed", { meta: { error: require_dist.errMsg(err) } });
8751
+ }
8752
+ this.logger?.info("audio-codec: decode session idled (no audio consumer)", { meta: { sessionId: sid } });
8753
+ }
8643
8754
  async close() {
8644
8755
  if (this.closed) return;
8645
8756
  this.closed = true;
@@ -8867,12 +8978,25 @@ var EncodedRingBuffer = class {
8867
8978
  clear() {
8868
8979
  this.packets.length = 0;
8869
8980
  }
8870
- /** Remove packets older than maxDurationMs based on PTS. */
8981
+ /**
8982
+ * Remove packets older than maxDurationMs based on PTS.
8983
+ *
8984
+ * PTS here is the source RTP timestamp (90kHz → ms), which is NOT
8985
+ * monotonic across the buffer's lifetime: an RTSP/rfc4571 redial re-bases
8986
+ * it to a fresh random value (RFC 3550) and the 32-bit field wraps every
8987
+ * ~13.25h. A purely `pts < cutoff` head-trim stalls forever whenever the
8988
+ * newest packet's pts drops below the stale head's — the buffer then
8989
+ * retains the entire pre-discontinuity history (an unbounded native-memory
8990
+ * leak of full-bitrate AnnexB copies). So we also evict any head packet
8991
+ * whose pts is GREATER than the newest packet's: it can only belong to a
8992
+ * prior, higher-based epoch that the source has since jumped away from.
8993
+ */
8871
8994
  evict() {
8872
8995
  if (this.packets.length === 0) return;
8873
- const cutoff = this.packets[this.packets.length - 1].pts - this.maxDurationMs;
8996
+ const newest = this.packets[this.packets.length - 1].pts;
8997
+ const cutoff = newest - this.maxDurationMs;
8874
8998
  let removeCount = 0;
8875
- while (removeCount < this.packets.length && this.packets[removeCount].pts < cutoff) removeCount++;
8999
+ while (removeCount < this.packets.length && (this.packets[removeCount].pts < cutoff || this.packets[removeCount].pts > newest)) removeCount++;
8876
9000
  if (removeCount > 0) this.packets.splice(0, removeCount);
8877
9001
  }
8878
9002
  };
@@ -9152,6 +9276,14 @@ var INITIAL_RECONNECT_DELAY_MS = 1e3;
9152
9276
  var MAX_RECONNECT_DELAY_MS = 3e4;
9153
9277
  /** Grace period before suspending after last consumer leaves. */
9154
9278
  var SUSPEND_GRACE_MS = 5e3;
9279
+ /**
9280
+ * Grace window before an idle audio-codec decode session is torn down
9281
+ * after the last audio consumer detaches. Mirrors the frame-handle /
9282
+ * suspend occupancy-linger pattern: a brief gap between consumers (e.g.
9283
+ * a WebRTC renegotiation or an analyzer re-subscribe) must not thrash
9284
+ * the upstream decode session create/destroy.
9285
+ */
9286
+ var AUDIO_CONSUMER_LINGER_MS = 5e3;
9155
9287
  /** Timeout for push-mode sources: if no packet arrives within this window, emit error placeholder. */
9156
9288
  var PUSH_STALL_TIMEOUT_MS = 15e3;
9157
9289
  var StreamBroker = class StreamBroker {
@@ -9227,6 +9359,12 @@ var StreamBroker = class StreamBroker {
9227
9359
  pushRtpFlagDepacketizer = null;
9228
9360
  audioRtpDecoder = null;
9229
9361
  audioCodecSession = null;
9362
+ /**
9363
+ * Pending occupancy-linger teardown of {@link audioCodecSession} after
9364
+ * the last audio consumer detached. Cancelled when a consumer returns
9365
+ * within {@link AUDIO_CONSUMER_LINGER_MS}. See {@link reconcileAudioConsumer}.
9366
+ */
9367
+ audioConsumerLingerTimer;
9230
9368
  audioRtpSeen = 0;
9231
9369
  /** Detected audio codec from RTSP DESCRIBE (e.g. 'PCMU', 'PCMA'). */
9232
9370
  audioCodec = null;
@@ -9440,6 +9578,7 @@ var StreamBroker = class StreamBroker {
9440
9578
  this.pipeServer = new StreamPipeServer(logger?.child("pipe-server"));
9441
9579
  this.rtspRestreamer = new RtspRestreamer(deviceId);
9442
9580
  if (logger) this.rtspRestreamer.setLogger(logger.child("rtsp"));
9581
+ this.rtspRestreamer.setRtpPreBufferSupplier(() => this.getRtpPreBuffer());
9443
9582
  this.preBuffer = new EncodedRingBuffer(StreamBroker.DEFAULT_PRE_BUFFER_SEC);
9444
9583
  this.decoderSeedBuffer = new EncodedRingBuffer(StreamBroker.DECODER_SEED_BUFFER_SEC);
9445
9584
  this.audioChunkPlane = new AudioChunkPlane(logger?.child("audio-chunk-plane"));
@@ -9688,11 +9827,54 @@ var StreamBroker = class StreamBroker {
9688
9827
  return false;
9689
9828
  }
9690
9829
  /**
9830
+ * True when at least one consumer needs DECODED audio. The audio-specific
9831
+ * subset of {@link hasDemand} — deliberately excludes the video
9832
+ * frame-handle plane so a video-only viewer never spins up the
9833
+ * AAC/Opus decode chain. `encodedCallbacks` covers WebRTC (its G.711
9834
+ * egress needs decoded PCM); `audioChunkPlane` covers the analyzer.
9835
+ */
9836
+ hasAudioConsumer() {
9837
+ return this.encodedCallbacks.size > 0 || this.audioChunkPlane.subscriberCount > 0;
9838
+ }
9839
+ /**
9840
+ * Demand-gate the audio-codec decode session lifecycle. Called from
9841
+ * every consumer add/remove edge (via {@link checkDemand}). While an
9842
+ * audio consumer exists, cancel any pending linger-close. When
9843
+ * consumers drop to zero, arm a short linger timer that tears the
9844
+ * upstream decode session + 40ms poll loop down (via
9845
+ * `AudioCodecSession.idle`) so a momentary gap between consumers
9846
+ * doesn't thrash create/destroy. The next gated packet re-arms the
9847
+ * session lazily through `ensureSession()`. No-op when no session is
9848
+ * armed (native PCMU/PCMA cameras never build one).
9849
+ */
9850
+ reconcileAudioConsumer() {
9851
+ if (!this.audioCodecSession) return;
9852
+ if (this.hasAudioConsumer()) {
9853
+ if (this.audioConsumerLingerTimer) {
9854
+ clearTimeout(this.audioConsumerLingerTimer);
9855
+ this.audioConsumerLingerTimer = void 0;
9856
+ }
9857
+ return;
9858
+ }
9859
+ if (this.audioConsumerLingerTimer) return;
9860
+ this.audioConsumerLingerTimer = setTimeout(() => {
9861
+ this.audioConsumerLingerTimer = void 0;
9862
+ if (this.hasAudioConsumer()) return;
9863
+ const session = this.audioCodecSession;
9864
+ if (!session) return;
9865
+ session.idle().catch((err) => {
9866
+ this.logger?.warn("audio-codec: idle teardown failed", { meta: { error: require_dist.errMsg(err) } });
9867
+ });
9868
+ }, AUDIO_CONSUMER_LINGER_MS);
9869
+ if (typeof this.audioConsumerLingerTimer.unref === "function") this.audioConsumerLingerTimer.unref();
9870
+ }
9871
+ /**
9691
9872
  * Check demand and schedule suspend/resume. Called after every subscriber
9692
9873
  * add/remove and after pre-buffer enable/disable.
9693
9874
  */
9694
9875
  checkDemand() {
9695
9876
  if (this.manualStop || this.stopping) return;
9877
+ this.reconcileAudioConsumer();
9696
9878
  if (this.hasDemand()) {
9697
9879
  if (!this.demandActive) {
9698
9880
  this.demandActive = true;
@@ -9838,7 +10020,7 @@ var StreamBroker = class StreamBroker {
9838
10020
  if (packet.type === "audio" && this.source?.type === "push" && this.audioCodecSession) {
9839
10021
  const codec = packet.codec.toLowerCase();
9840
10022
  if (codec !== "pcmu" && codec !== "pcma") {
9841
- this.audioCodecSession.pushRawFrame(packet.data);
10023
+ if (this.hasAudioConsumer()) this.audioCodecSession.pushRawFrame(packet.data);
9842
10024
  return;
9843
10025
  }
9844
10026
  }
@@ -10279,7 +10461,8 @@ var StreamBroker = class StreamBroker {
10279
10461
  logger: this.logger?.child("frame-handle-plane"),
10280
10462
  resolveStreamInfo: () => this.resolveFrameHandleStreamInfo(),
10281
10463
  localNodeId: this.localNodeId,
10282
- getSeedPackets: () => this.decoderSeedBuffer.getPackets()
10464
+ getSeedPackets: () => this.decoderSeedBuffer.getPackets(),
10465
+ isDebugEnabled: () => this.streamingDebug
10283
10466
  });
10284
10467
  return this.frameHandlePlane;
10285
10468
  }
@@ -10361,6 +10544,17 @@ var StreamBroker = class StreamBroker {
10361
10544
  subscribedAt: entry.subscribedAt,
10362
10545
  packetsDelivered: entry.packetsDelivered
10363
10546
  });
10547
+ if (this._preBufferEnabled && encoded.length === 0) encoded.push({
10548
+ id: "prebuffer-hold",
10549
+ channel: "annexb",
10550
+ attribution: {
10551
+ kind: "warmup",
10552
+ userAgent: "prebuffer",
10553
+ media: "video"
10554
+ },
10555
+ subscribedAt: this.startedAt,
10556
+ packetsDelivered: 0
10557
+ });
10364
10558
  return {
10365
10559
  rtsp: this.rtspRestreamer.listSessionInfos(),
10366
10560
  decoded: (this.frameHandlePlane?.listSubscribers() ?? []).map((s) => ({
@@ -10726,7 +10920,7 @@ var StreamBroker = class StreamBroker {
10726
10920
  hasCodecSession: this.audioCodecSession !== null
10727
10921
  } });
10728
10922
  this.audioRtpDecoder?.processPacket(rtpData);
10729
- this.audioCodecSession?.processPacket(rtpData);
10923
+ if (this.hasAudioConsumer()) this.audioCodecSession?.processPacket(rtpData);
10730
10924
  if ((this.audioCodec === "PCMU" || this.audioCodec === "PCMA") && this.encodedCallbacks.size > 0 && rtpData.length > 12) {
10731
10925
  const payload = rtpData.subarray(12);
10732
10926
  if (payload.length > 0) {
@@ -11207,6 +11401,10 @@ var StreamBroker = class StreamBroker {
11207
11401
  this.audioRtpDecoder.flush();
11208
11402
  this.audioRtpDecoder = null;
11209
11403
  }
11404
+ if (this.audioConsumerLingerTimer) {
11405
+ clearTimeout(this.audioConsumerLingerTimer);
11406
+ this.audioConsumerLingerTimer = void 0;
11407
+ }
11210
11408
  if (this.audioCodecSession) {
11211
11409
  const session = this.audioCodecSession;
11212
11410
  this.audioCodecSession = null;
@@ -14257,6 +14455,11 @@ var StreamBrokerManager = class {
14257
14455
  break;
14258
14456
  }
14259
14457
  this.assignments.set(deviceId, nextAssignment);
14458
+ if (current.map.high !== newMap.high) for (const camStreamId of [current.map.high, newMap.high]) {
14459
+ if (!camStreamId) continue;
14460
+ const broker = this.brokers.get(brokerIdFor(deviceId, camStreamId));
14461
+ if (broker) this.applyPreBufferConfig(broker, deviceId, camStreamId);
14462
+ }
14260
14463
  for (const [camId, prevCount] of oldPushConsumers) if ((newPushConsumers.get(camId) ?? 0) === 0 && prevCount > 0) this.emitCamStreamIdle(deviceId, camId);
14261
14464
  for (const [camId, nextCount] of newPushConsumers) if ((oldPushConsumers.get(camId) ?? 0) === 0 && nextCount > 0) {
14262
14465
  let triggerProfile = "mid";
@@ -14462,7 +14665,7 @@ var StreamBrokerManager = class {
14462
14665
  broker.setRestreamers(this.restreamers);
14463
14666
  broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
14464
14667
  broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
14465
- this.applyPreBufferConfig(broker, deviceId, camStreamId, cam);
14668
+ this.applyPreBufferConfig(broker, deviceId, camStreamId);
14466
14669
  if (this.deviceOverrides.get(deviceId)?.streamingDebug) broker.setStreamingDebug(true);
14467
14670
  broker.setSourceProvider(() => {
14468
14671
  const latest = this.cameraStreams.get(deviceId)?.get(camStreamId);
@@ -14598,34 +14801,48 @@ var StreamBrokerManager = class {
14598
14801
  };
14599
14802
  }
14600
14803
  /**
14804
+ * Whether a stream is warmed BY DEFAULT (no explicit override): the device's
14805
+ * `high` profile, on a non-battery camera. Single source of truth used by both
14806
+ * the broker-config default (`applyPreBufferConfig`) and the settings-patch
14807
+ * override synthesis (`defaultStreamPreBuffer`) so a seconds-only patch never
14808
+ * silently disables the high-profile warm default.
14809
+ */
14810
+ isDefaultWarm(deviceId, camStreamId) {
14811
+ return findProfileForStream(this.assignments.get(deviceId)?.map ?? {}, camStreamId) === "high" && !this.deviceHasFeature(deviceId, require_dist.DeviceFeature.BatteryOperated);
14812
+ }
14813
+ /**
14601
14814
  * Default `StreamPreBuffer` for a brand-new override entry — mirrors
14602
- * the UI-side default in `getDeviceSettingsContribution`. Pre-buffer
14603
- * is OFF by default for every stream. Operators opt in per-stream
14604
- * via the device-settings sub-tabs; detection / recording paths
14605
- * pass `forceForDetection` to `getEffectivePreBufferSec` when they
14606
- * need a buffered window irrespective of the operator toggle.
14815
+ * the effective broker default: `enabled` reflects the high-profile warm
14816
+ * default (`isDefaultWarm`), so a seconds-only patch on a warm high stream
14817
+ * inherits `enabled: true` instead of silently forcing it OFF. Operators
14818
+ * still override explicitly per-stream via the device-settings sub-tabs;
14819
+ * detection / recording paths pass `forceForDetection` to
14820
+ * `getEffectivePreBufferSec` when they need a buffered window irrespective
14821
+ * of the operator toggle.
14607
14822
  */
14608
- defaultStreamPreBuffer(_deviceId, _camStreamId) {
14823
+ defaultStreamPreBuffer(deviceId, camStreamId) {
14609
14824
  return {
14610
- enabled: false,
14825
+ enabled: this.isDefaultWarm(deviceId, camStreamId),
14611
14826
  seconds: this.defaultPreBufferSec
14612
14827
  };
14613
14828
  }
14614
14829
  /**
14615
14830
  * Apply the effective pre-buffer config to a fresh broker instance.
14616
- * Default behaviour is OFF for every cam stream keeps RAM and the
14617
- * upstream reader idle until the operator opts in or a detection /
14618
- * recording subscriber attaches.
14831
+ * Default behaviour: the HIGH profile stream is warm (see below);
14832
+ * every other stream stays OFF — idle until the operator opts in or
14833
+ * a detection / recording subscriber attaches. An explicit per-stream
14834
+ * operator override always wins over both defaults.
14619
14835
  */
14620
- applyPreBufferConfig(broker, deviceId, camStreamId, _cam) {
14836
+ applyPreBufferConfig(broker, deviceId, camStreamId) {
14621
14837
  const perStream = this.deviceOverrides.get(deviceId)?.preBuffer?.[camStreamId];
14622
14838
  if (perStream) {
14623
14839
  broker.setPreBufferEnabled(perStream.enabled);
14624
14840
  broker.setPreBufferDuration(perStream.enabled ? perStream.seconds : 0);
14625
14841
  return;
14626
14842
  }
14627
- broker.setPreBufferEnabled(false);
14628
- broker.setPreBufferDuration(0);
14843
+ const defaultWarm = this.isDefaultWarm(deviceId, camStreamId);
14844
+ broker.setPreBufferEnabled(defaultWarm);
14845
+ broker.setPreBufferDuration(defaultWarm ? this.defaultPreBufferSec : 0);
14629
14846
  }
14630
14847
  async fanOutRegistration(deviceId, brokerId, cam, broker) {
14631
14848
  const log = this.logger.withTags({
@@ -17410,6 +17627,15 @@ var BROKER_METRICS_HEARTBEAT_MS = 3e4;
17410
17627
  /** Throttle for persisting the per-stream probe map — bounds writes regardless
17411
17628
  * of streaming churn while keeping the durable copy recent enough for restart. */
17412
17629
  var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
17630
+ /**
17631
+ * Defensive timeout for the per-session `networkQuality.getDeviceStats` poll.
17632
+ * The stat is an OPTIONAL input to the adaptive downgrade ladder — never worth
17633
+ * blocking the loop for. When the hub-resident provider is transiently
17634
+ * undiscoverable the child's unowned-cap routing falls to the Moleculer
17635
+ * broker-fallback, whose discovery wait is ~30s; without this bound a single
17636
+ * poll would stall the adaptive loop for that long. On timeout we simply treat
17637
+ * the sample as absent (the RTCP Receiver-Report path drives the ladder anyway). */
17638
+ var NETWORK_QUALITY_STATS_TIMEOUT_MS = 2e3;
17413
17639
  var StreamBrokerAddon = class extends require_dist.BaseAddon {
17414
17640
  brokerManager = null;
17415
17641
  /** ffmpeg binary path from the `ffmpeg` config section, mirrored here so the
@@ -17738,10 +17964,14 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
17738
17964
  }
17739
17965
  },
17740
17966
  clientLossPct: async (deviceId) => {
17967
+ const ctrl = new AbortController();
17968
+ const timer = setTimeout(() => ctrl.abort(), NETWORK_QUALITY_STATS_TIMEOUT_MS);
17741
17969
  try {
17742
- return (await this.ctx.api.networkQuality.getDeviceStats.query({ deviceId }))?.client?.packetLossPercent ?? null;
17970
+ return (await this.ctx.api.networkQuality.getDeviceStats.query({ deviceId }, { signal: ctrl.signal }))?.client?.packetLossPercent ?? null;
17743
17971
  } catch {
17744
17972
  return null;
17973
+ } finally {
17974
+ clearTimeout(timer);
17745
17975
  }
17746
17976
  },
17747
17977
  resolveTranscodeDecodeHwAccel: () => this.resolveTranscodeDecodeHwAccel(),