@camstack/addon-pipeline 1.2.140 → 1.2.142

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.
@@ -171,6 +171,25 @@ var CH_RTP = declareLogChannel({
171
171
  defaultLevel: "info",
172
172
  perDevice: false
173
173
  });
174
+ /**
175
+ * Every channel this addon declares, as a VALUE.
176
+ *
177
+ * `addon.ts` used to reach the declarations through a bare
178
+ * `import './log-channels.js'` — an import with no binding, kept alive only by
179
+ * a comment saying "never drop this as unused". On 2026-08-29 the shipped
180
+ * bundle contained this module TWICE: inlined into `stream-broker/index.mjs`
181
+ * and again in a shared chunk that same entry statically imports. Loading the
182
+ * addon ran `declareLogChannel` twice, the duplicate was refused — correctly,
183
+ * that guard is doing its job — and the whole stream-broker failed to load.
184
+ * The hub had no broker and no live streams until it was rolled back.
185
+ *
186
+ * The duplication has NOT been reproduced since, from a clean dist or a dirty
187
+ * one, so this is not offered as a proven fix: it removes the freedom the
188
+ * bundler took. A value binding must resolve to one module instance; a
189
+ * side-effect import need not. `scripts/check-log-channel-single-declaration.ts`
190
+ * is the part that actually holds, because it reads the artefact.
191
+ */
192
+ var STREAM_BROKER_LOG_CHANNELS = [CH_WEBRTC, CH_RTP];
174
193
  //#endregion
175
194
  //#region src/stream-broker/stream-broker/probed-summary.ts
176
195
  /**
@@ -1818,15 +1837,32 @@ var DEFAULT_RECORDING_PRE_ROLL_MS = 1e4;
1818
1837
  */
1819
1838
  var PRE_ROLL_UNKNOWN_BITRATE_BYTES = 8 * 1024 * 1024;
1820
1839
  /**
1821
- * Absolute per-stream ceiling on retained pre-roll history.
1840
+ * Hard ceiling on the WHOLE source-RTP ring (history + the current GOP).
1841
+ *
1842
+ * Lives here, next to the pre-roll budget, because the two numbers are one
1843
+ * number: a history budget above the ring cap is not a wider window, it is a
1844
+ * ring that overflows and calls `resetRtpPreBuffer`, which drops the join
1845
+ * bootstrap of EVERY consumer — WebRTC viewers included — for as long as the
1846
+ * condition holds. Keeping them in separate files is how they came to disagree.
1847
+ * Read by `StreamBroker.captureRtpForPreBuffer`.
1848
+ */
1849
+ var RTP_RING_MAX_BYTES = 24 * 1024 * 1024;
1850
+ /**
1851
+ * Absolute per-stream ceiling on retained pre-roll HISTORY.
1822
1852
  *
1823
1853
  * The bitrate-derived budget is what the window COSTS; this is the most any one
1824
- * stream may spend regardless. 32 MiB is 4x the flat cap it replaces and covers
1825
- * 15 s of a 16 Mbps stream with headroom; past it the operator has configured a
1826
- * window this node cannot afford on that camera, and the broker says so rather
1827
- * than delivering silently less (D192).
1854
+ * stream may spend regardless. Past it the operator has configured a window this
1855
+ * node cannot afford on that camera, and the broker says so rather than
1856
+ * delivering silently less (D192).
1857
+ *
1858
+ * **DERIVED, not chosen.** It was a free-standing `32 * 1024 * 1024` — 8 MiB
1859
+ * ABOVE the ring it has to fit inside. A ≳13 Mbps stream at a 10 s window was
1860
+ * therefore authorised to overflow its own ring and cycle `resetRtpPreBuffer`:
1861
+ * the pre-roll knob, turned up, would have taken every viewer's join bootstrap
1862
+ * with it. Deriving it from {@link RTP_RING_MAX_BYTES} minus the current-GOP
1863
+ * reserve makes the inversion unrepresentable rather than merely fixed.
1828
1864
  */
1829
- var PRE_ROLL_ABSOLUTE_MAX_BYTES = 32 * 1024 * 1024;
1865
+ var PRE_ROLL_ABSOLUTE_MAX_BYTES = RTP_RING_MAX_BYTES - 8 * 1024 * 1024;
1830
1866
  /**
1831
1867
  * Headroom over the OBSERVED mean bitrate.
1832
1868
  *
@@ -1856,7 +1892,7 @@ function preRollBudgetBytes(windowMs, observedKbps) {
1856
1892
  clamped: false
1857
1893
  };
1858
1894
  const raw = Math.round(windowMs * observedKbps * PRE_ROLL_BITRATE_HEADROOM / 8);
1859
- if (raw > 33554432) return {
1895
+ if (raw > 16777216) return {
1860
1896
  bytes: PRE_ROLL_ABSOLUTE_MAX_BYTES,
1861
1897
  clamped: true
1862
1898
  };
@@ -2865,6 +2901,12 @@ var RtspRestreamer = class {
2865
2901
  */
2866
2902
  servePendingSessionsRtp() {
2867
2903
  if (this.pendingKeyframe.size === 0) return;
2904
+ const ready = [];
2905
+ for (const sessionId of this.pendingKeyframe) {
2906
+ if (this.activeReplays.has(sessionId)) continue;
2907
+ if (this.sessions.get(sessionId)?.isPlaying()) ready.push(sessionId);
2908
+ }
2909
+ if (ready.length === 0) return;
2868
2910
  const ringPackets = this.rtpPreBufferSupplier?.() ?? [];
2869
2911
  const fromRing = ringPackets.length > 0;
2870
2912
  const primePackets = fromRing ? ringPackets.map((data) => ({
@@ -2874,12 +2916,6 @@ var RtspRestreamer = class {
2874
2916
  if (primePackets.length === 0) return;
2875
2917
  const firstTs = primePackets[0].data.readUInt32BE(4);
2876
2918
  const spanMs = (primePackets[primePackets.length - 1].data.readUInt32BE(4) - firstTs | 0) / 90;
2877
- const ready = [];
2878
- for (const sessionId of this.pendingKeyframe) {
2879
- if (this.activeReplays.has(sessionId)) continue;
2880
- if (this.sessions.get(sessionId)?.isPlaying()) ready.push(sessionId);
2881
- }
2882
- if (ready.length === 0) return;
2883
2919
  const preRollPlans = /* @__PURE__ */ new Map();
2884
2920
  const bound = this.currentPrimeBound();
2885
2921
  /**
@@ -3506,15 +3542,7 @@ var RtspSession = class {
3506
3542
  return "closed-backlog";
3507
3543
  }
3508
3544
  }
3509
- const channel = this.tracks[0].rtpChannel;
3510
- const frame = Buffer.allocUnsafe(4 + packet.data.length);
3511
- frame[0] = 36;
3512
- frame[1] = channel;
3513
- frame.writeUInt16BE(packet.data.length, 2);
3514
- packet.data.copy(frame, 4);
3515
- this.socket.write(frame);
3516
- this.lastRtpAt = Date.now();
3517
- this.bytesSent += frame.length;
3545
+ this.writeInterleaved(this.tracks[0].rtpChannel, packet.data);
3518
3546
  return "written";
3519
3547
  }
3520
3548
  /** Send an audio RTP packet via TCP interleaved framing on the audio track channel. */
@@ -3525,17 +3553,60 @@ var RtspSession = class {
3525
3553
  this.close();
3526
3554
  return "closed-backlog";
3527
3555
  }
3528
- const channel = this.tracks[1].rtpChannel;
3529
- const frame = Buffer.allocUnsafe(4 + packet.data.length);
3530
- frame[0] = 36;
3531
- frame[1] = channel;
3532
- frame.writeUInt16BE(packet.data.length, 2);
3533
- packet.data.copy(frame, 4);
3534
- this.socket.write(frame);
3535
- this.lastRtpAt = Date.now();
3536
- this.bytesSent += frame.length;
3556
+ this.writeInterleaved(this.tracks[1].rtpChannel, packet.data);
3537
3557
  return "written";
3538
3558
  }
3559
+ /**
3560
+ * Write ONE interleaved frame as a 4-byte header plus the caller's payload,
3561
+ * **shared by reference** — the payload is never copied.
3562
+ *
3563
+ * ## Why this is the whole point
3564
+ *
3565
+ * This used to be `Buffer.allocUnsafe(4 + payload.length)` + `payload.copy()`:
3566
+ * a FULL copy of every packet, once per connected session, to prepend four
3567
+ * constant bytes. At ~3 000 pps ingest and 2–3 sessions per broker (detection
3568
+ * dial, recording dial, egress legs) that is ~6 000–9 000 full-packet
3569
+ * allocations per second, and `stream-broker` was measured at 95.5 % of a core
3570
+ * with **68 % of it in GC**. Scrypted's prebuffer — same fundamental ring
3571
+ * design — writes the header and the shared payload as two writes and does
3572
+ * exactly one allocation per packet, total, for every consumer. This closes
3573
+ * that gap. N connected clients now cause ONE payload allocation (the ingest
3574
+ * copy), not N.
3575
+ *
3576
+ * ## Why sharing the payload is safe, and what would make it unsafe
3577
+ *
3578
+ * The payload buffer handed here is ALREADY retained, by reference, in the
3579
+ * broker's `rtpRing` for the life of a GOP (`captureRtpForPreBuffer` pushes
3580
+ * the same object). A pending `socket.write` cannot outlive that, so the
3581
+ * lifetime requirement this adds is strictly weaker than one the broker
3582
+ * already meets. Its provenance is a private allocation per packet —
3583
+ * `Buffer.from(readBuffer.subarray(...))` in `rtsp-client.ts`'s `onData`, for
3584
+ * video and audio alike — never a view of the reader's socket buffer, which
3585
+ * advances on the next read. Nothing in the broker writes into a captured
3586
+ * packet: every mutation in this path targets a freshly allocated buffer.
3587
+ *
3588
+ * The rule to keep: **`socket.write` forbids MUTATION before the write
3589
+ * completes, not retention.** So the header, which IS built per call, must
3590
+ * stay a fresh allocation — a reused 4-byte scratch buffer would be rewritten
3591
+ * under an in-flight write and corrupt the frame length of a packet already
3592
+ * queued. Four bytes from the Buffer pool is a pointer bump; the copy this
3593
+ * replaces was ~1.4 KB.
3594
+ *
3595
+ * `cork`/`uncork` keeps the pair a single `writev` syscall, so removing the
3596
+ * copy does not buy an extra write syscall per packet.
3597
+ */
3598
+ writeInterleaved(channel, payload) {
3599
+ const header = Buffer.allocUnsafe(4);
3600
+ header[0] = 36;
3601
+ header[1] = channel;
3602
+ header.writeUInt16BE(payload.length, 2);
3603
+ this.socket.cork();
3604
+ this.socket.write(header);
3605
+ this.socket.write(payload);
3606
+ this.socket.uncork();
3607
+ this.lastRtpAt = Date.now();
3608
+ this.bytesSent += 4 + payload.length;
3609
+ }
3539
3610
  close() {
3540
3611
  if (this.closed) return;
3541
3612
  this.closed = true;
@@ -8547,8 +8618,6 @@ var Rfc4571Reader = class {
8547
8618
  }
8548
8619
  }
8549
8620
  };
8550
- //#endregion
8551
- //#region src/stream-broker/rtsp/rtp-depacketizer.ts
8552
8621
  var H264_NAL_SPS = 7;
8553
8622
  var H264_NAL_PPS = 8;
8554
8623
  var H264_NAL_IDR = 5;
@@ -8560,6 +8629,29 @@ var H265_NAL_IRAP_MIN = 16;
8560
8629
  var H265_NAL_IRAP_MAX = 21;
8561
8630
  var H265_NAL_AP = 48;
8562
8631
  /**
8632
+ * Allocate a NAL buffer of `byteLength` payload bytes with the 4-byte Annex-B
8633
+ * start code already written into reserved leading headroom.
8634
+ *
8635
+ * The caller fills from {@link ANNEXB_START_CODE_LENGTH} onwards. This is the
8636
+ * single place a start code is produced on the ingest path — the broker used to
8637
+ * build one with `Buffer.from([0, 0, 0, 1])` per NAL and then concat it onto a
8638
+ * NAL this function had already allocated, which copied every NAL twice.
8639
+ */
8640
+ function allocAnnexBNal(byteLength) {
8641
+ const buf = Buffer.allocUnsafe(4 + byteLength);
8642
+ buf[0] = 0;
8643
+ buf[1] = 0;
8644
+ buf[2] = 0;
8645
+ buf[3] = 1;
8646
+ return buf;
8647
+ }
8648
+ /** Total byte length of the reassembly fragments, without allocating a closure. */
8649
+ function totalFragmentBytes(fragments) {
8650
+ let total = 0;
8651
+ for (const fragment of fragments) total += fragment.length;
8652
+ return total;
8653
+ }
8654
+ /**
8563
8655
  * RTP → Annex-B NAL depacketizer.
8564
8656
  *
8565
8657
  * Takes raw RTP packets (as received from TCP interleaved RTSP) and
@@ -8611,7 +8703,7 @@ var RtpDepacketizer = class {
8611
8703
  }
8612
8704
  processH264(payload, timestamp, marker) {
8613
8705
  const nalType = payload[0] & 31;
8614
- if (nalType >= 1 && nalType <= 23) this.emitNal(payload, timestamp, marker);
8706
+ if (nalType >= 1 && nalType <= 23) this.emitNal(this.copyIntoAnnexB(payload), timestamp, marker);
8615
8707
  else if (nalType === H264_NAL_STAP_A) this.processH264StapA(payload, timestamp, marker);
8616
8708
  else if (nalType === 28) this.processH264FuA(payload, timestamp, marker);
8617
8709
  }
@@ -8622,7 +8714,8 @@ var RtpDepacketizer = class {
8622
8714
  const nalSize = payload.readUInt16BE(offset);
8623
8715
  offset += 2;
8624
8716
  if (offset + nalSize > payload.length) break;
8625
- const nal = Buffer.from(payload.subarray(offset, offset + nalSize));
8717
+ const nal = allocAnnexBNal(nalSize);
8718
+ payload.copy(nal, 4, offset, offset + nalSize);
8626
8719
  offset += nalSize;
8627
8720
  this.emitNal(nal, timestamp, marker && offset >= payload.length);
8628
8721
  }
@@ -8642,10 +8735,9 @@ var RtpDepacketizer = class {
8642
8735
  } else if (this.fuBuffer.length > 0 && this.fuTimestamp === timestamp) {
8643
8736
  this.fuBuffer.push(payload.subarray(2));
8644
8737
  if (isEnd) {
8645
- const totalSize = this.fuBuffer.reduce((sum, b) => sum + b.length, 0);
8646
- const nal = Buffer.allocUnsafe(1 + totalSize);
8647
- nal[0] = this.fuFirstByte;
8648
- let offset = 1;
8738
+ const nal = allocAnnexBNal(1 + totalFragmentBytes(this.fuBuffer));
8739
+ nal[4] = this.fuFirstByte;
8740
+ let offset = 5;
8649
8741
  for (const fragment of this.fuBuffer) {
8650
8742
  fragment.copy(nal, offset);
8651
8743
  offset += fragment.length;
@@ -8658,7 +8750,7 @@ var RtpDepacketizer = class {
8658
8750
  processH265(payload, timestamp, marker) {
8659
8751
  if (payload.length < 2) return;
8660
8752
  const nalType = payload[0] >> 1 & 63;
8661
- if (nalType < 48) this.emitNal(payload, timestamp, marker);
8753
+ if (nalType < 48) this.emitNal(this.copyIntoAnnexB(payload), timestamp, marker);
8662
8754
  else if (nalType === H265_NAL_AP) this.processH265Ap(payload, timestamp, marker);
8663
8755
  else if (nalType === 49) this.processH265Fu(payload, timestamp, marker);
8664
8756
  }
@@ -8669,7 +8761,8 @@ var RtpDepacketizer = class {
8669
8761
  const nalSize = payload.readUInt16BE(offset);
8670
8762
  offset += 2;
8671
8763
  if (offset + nalSize > payload.length) break;
8672
- const nal = Buffer.from(payload.subarray(offset, offset + nalSize));
8764
+ const nal = allocAnnexBNal(nalSize);
8765
+ payload.copy(nal, 4, offset, offset + nalSize);
8673
8766
  offset += nalSize;
8674
8767
  this.emitNal(nal, timestamp, marker && offset >= payload.length);
8675
8768
  }
@@ -8691,10 +8784,9 @@ var RtpDepacketizer = class {
8691
8784
  } else if (this.fuBuffer.length > 0 && this.fuTimestamp === timestamp) {
8692
8785
  this.fuBuffer.push(payload.subarray(3));
8693
8786
  if (isEnd && this.fuH265Header) {
8694
- const totalSize = this.fuBuffer.reduce((sum, b) => sum + b.length, 0);
8695
- const nal = Buffer.allocUnsafe(2 + totalSize);
8696
- this.fuH265Header.copy(nal, 0);
8697
- let offset = 2;
8787
+ const nal = allocAnnexBNal(2 + totalFragmentBytes(this.fuBuffer));
8788
+ this.fuH265Header.copy(nal, 4);
8789
+ let offset = 6;
8698
8790
  for (const fragment of this.fuBuffer) {
8699
8791
  fragment.copy(nal, offset);
8700
8792
  offset += fragment.length;
@@ -8708,11 +8800,24 @@ var RtpDepacketizer = class {
8708
8800
  this.fuH265Header = null;
8709
8801
  }
8710
8802
  }
8711
- emitNal(nal, timestamp, marker) {
8803
+ /** Copy a payload view into a fresh Annex-B buffer (start code + payload). */
8804
+ copyIntoAnnexB(payload) {
8805
+ const buf = allocAnnexBNal(payload.length);
8806
+ payload.copy(buf, 4);
8807
+ return buf;
8808
+ }
8809
+ /**
8810
+ * @param annexB the NAL WITH its 4-byte start code already in front — every
8811
+ * caller builds it that way via {@link allocAnnexBNal}, so `nal` below is a
8812
+ * free view rather than a second allocation.
8813
+ */
8814
+ emitNal(annexB, timestamp, marker) {
8815
+ const nal = annexB.subarray(4);
8712
8816
  const keyframe = this.codec === "h264" ? this.isH264Keyframe(nal) : this.isH265Keyframe(nal);
8713
8817
  const parameterSet = this.codec === "h264" ? this.isH264ParameterSet(nal) : this.isH265ParameterSet(nal);
8714
8818
  this.onNal({
8715
8819
  nal,
8820
+ annexB,
8716
8821
  keyframe,
8717
8822
  parameterSet,
8718
8823
  timestamp,
@@ -11051,6 +11156,8 @@ var StreamBroker = class StreamBroker {
11051
11156
  * consumer reads from. `null` until a consumer subscribes.
11052
11157
  */
11053
11158
  frameHandlePlane = null;
11159
+ /** One-shot latch for the "seed ring not filled" line — see `pushEncodedPacket`. */
11160
+ seedRingSkipLogged = false;
11054
11161
  decoderApi;
11055
11162
  audioCodecApi;
11056
11163
  logger;
@@ -11254,7 +11361,20 @@ var StreamBroker = class StreamBroker {
11254
11361
  * packet; push / RTMP (provider-driven) sources sit out because they have
11255
11362
  * no native reader to tear down.
11256
11363
  */
11257
- rtpActivityWatchdog;
11364
+ rtpActivitySweep;
11365
+ /**
11366
+ * Wall-clock ms of the most recent source-RTP packet, or `0` while the
11367
+ * watchdog is disarmed.
11368
+ *
11369
+ * This is the whole of the per-packet work. The watchdog used to re-arm a
11370
+ * `setTimeout` on EVERY packet (`clearTimeout` + `setTimeout`), and the
11371
+ * comment here called that "negligible". It was not: at 30 cameras and
11372
+ * 80-100 packets/s each, that is ~3 000 `Timeout` objects allocated,
11373
+ * inserted into and removed from libuv's timer heap every second, in the
11374
+ * process that already spends 68% of a full core in GC. Writing a number
11375
+ * allocates nothing; one sweep timer per broker replaces ~100 arms/s.
11376
+ */
11377
+ lastRtpActivityAt = 0;
11258
11378
  /** Number of times the activity watchdog fired in this broker's lifetime
11259
11379
  * (incremented on expiry, NOT on arm/cancel). Surfaced via the test
11260
11380
  * seam so unit tests can assert recovery behavior without inspecting
@@ -11266,6 +11386,16 @@ var StreamBroker = class StreamBroker {
11266
11386
  * real stall is detected before Chrome's ~15s ICE-disconnect timer
11267
11387
  * has any chance to fire. */
11268
11388
  static RTP_ACTIVITY_TIMEOUT_MS = 8e3;
11389
+ /**
11390
+ * How often the single sweep timer checks {@link lastRtpActivityAt}.
11391
+ *
11392
+ * The stall is still declared at exactly `RTP_ACTIVITY_TIMEOUT_MS` of
11393
+ * silence; this only bounds how late that verdict can be observed (one
11394
+ * sweep, so ≤ 9s worst case rather than exactly 8s). That slack is far
11395
+ * inside Chrome's ~15s ICE-disconnect timer, which is the deadline the
11396
+ * timeout was chosen against.
11397
+ */
11398
+ static RTP_ACTIVITY_SWEEP_MS = 1e3;
11269
11399
  manualStop = false;
11270
11400
  stopping = false;
11271
11401
  pipeServer;
@@ -11426,10 +11556,6 @@ var StreamBroker = class StreamBroker {
11426
11556
  joinBurstBound = DEFAULT_PRIME_BURST_BOUND;
11427
11557
  /** Per-message throttle state for {@link reportThrottled}. */
11428
11558
  throttledReports = /* @__PURE__ */ new Map();
11429
- /** Memory ceiling for the RTP ring. A GOP that exceeds this (pathological
11430
- * long-GOP high-bitrate stream) drops the bootstrap rather than balloon
11431
- * memory — late joiners fall back to waiting for the next keyframe. */
11432
- static RTP_RING_MAX_BYTES = 24 * 1024 * 1024;
11433
11559
  /** Last budget printed by {@link reportPreRollClamp}, so the line fires on a
11434
11560
  * TRANSITION rather than on every keyframe. */
11435
11561
  preRollClampLogged = false;
@@ -12197,9 +12323,18 @@ var StreamBroker = class StreamBroker {
12197
12323
  }
12198
12324
  }
12199
12325
  if (packet.type === "video" && this.firstKeyframeReceived) {
12200
- const decoderPacket = this.decoderPacketWithParamSets(packet);
12201
- this.decoderSeedBuffer.push(decoderPacket);
12202
- this.frameHandlePlane?.pushPacket(decoderPacket);
12326
+ const plane = this.frameHandlePlane;
12327
+ if (plane) {
12328
+ const decoderPacket = this.decoderPacketWithParamSets(packet);
12329
+ this.decoderSeedBuffer.push(decoderPacket);
12330
+ plane.pushPacket(decoderPacket);
12331
+ } else if (!this.seedRingSkipLogged) {
12332
+ this.seedRingSkipLogged = true;
12333
+ this.logger?.info("decoder seed ring not filled — no frame-handle consumer on this broker", {
12334
+ tags: { deviceId: this.numericDeviceId },
12335
+ meta: { seedSec: StreamBroker.DECODER_SEED_BUFFER_SEC }
12336
+ });
12337
+ }
12203
12338
  }
12204
12339
  }
12205
12340
  /**
@@ -12423,11 +12558,11 @@ var StreamBroker = class StreamBroker {
12423
12558
  this.rtpRingHasKeyframe = true;
12424
12559
  }
12425
12560
  this.rtpRingPrevMarker = marker;
12426
- if (this.rtpRingBytes > StreamBroker.RTP_RING_MAX_BYTES) {
12561
+ if (this.rtpRingBytes > 25165824) {
12427
12562
  this.reportThrottled("rtp pre-buffer ring exceeded cap — join bootstrap dropped", {
12428
12563
  bytes: this.rtpRingBytes,
12429
12564
  packets: this.rtpRing.length,
12430
- capBytes: StreamBroker.RTP_RING_MAX_BYTES
12565
+ capBytes: RTP_RING_MAX_BYTES
12431
12566
  });
12432
12567
  this.resetRtpPreBuffer();
12433
12568
  }
@@ -12790,7 +12925,7 @@ var StreamBroker = class StreamBroker {
12790
12925
  */
12791
12926
  getRtpActivityWatchdogStatus() {
12792
12927
  return {
12793
- armed: this.rtpActivityWatchdog !== void 0,
12928
+ armed: this.rtpActivitySweep !== void 0,
12794
12929
  firedCount: this.rtpActivityWatchdogFiredCount
12795
12930
  };
12796
12931
  }
@@ -13489,28 +13624,16 @@ var StreamBroker = class StreamBroker {
13489
13624
  const isH264 = codec === "h264";
13490
13625
  const nalType = isH264 ? nal[0] & 31 : (nal[0] & 126) >> 1;
13491
13626
  if (isH264 ? nalType === 7 || nalType === 8 : nalType === 32 || nalType === 33 || nalType === 34) {
13492
- const startCode = Buffer.from([
13493
- 0,
13494
- 0,
13495
- 0,
13496
- 1
13497
- ]);
13498
- this.pendingParamNals.push(Buffer.concat([startCode, nal]));
13627
+ this.pendingParamNals.push(depacketized.annexB);
13499
13628
  return;
13500
13629
  }
13501
- const startCode = Buffer.from([
13502
- 0,
13503
- 0,
13504
- 0,
13505
- 1
13506
- ]);
13507
13630
  const hadParamSets = this.pendingParamNals.length > 0;
13508
13631
  let annexBData;
13509
13632
  if (hadParamSets) {
13510
- this.pendingParamNals.push(Buffer.concat([startCode, nal]));
13633
+ this.pendingParamNals.push(depacketized.annexB);
13511
13634
  annexBData = Buffer.concat(this.pendingParamNals);
13512
13635
  this.pendingParamNals = [];
13513
- } else annexBData = Buffer.concat([startCode, nal]);
13636
+ } else annexBData = depacketized.annexB;
13514
13637
  const pts = Math.floor(timestamp / 90);
13515
13638
  const packet = {
13516
13639
  type: "video",
@@ -13653,22 +13776,38 @@ var StreamBroker = class StreamBroker {
13653
13776
  * sources stay covered by the same arm/cancel lifecycle (the expiry
13654
13777
  * path is reader-aware — it does nothing when no reader is alive).
13655
13778
  *
13656
- * Cheap (clearTimeout + setTimeout); called at ~50-100Hz on a healthy
13657
- * stream the overhead is negligible compared to the depacketize and
13658
- * fan-out work each packet already does.
13779
+ * ALLOCATION-FREE per packet: it stamps {@link lastRtpActivityAt} and
13780
+ * arms ONE {@link StreamBroker.RTP_ACTIVITY_SWEEP_MS} interval the first
13781
+ * time. Do not turn this back into a per-packet `clearTimeout` +
13782
+ * `setTimeout` — that is a `Timeout` allocation and two libuv timer-heap
13783
+ * operations on the highest-frequency path in the busiest process.
13659
13784
  */
13660
13785
  kickRtpActivityWatchdog() {
13661
13786
  if (this.manualStop || this.stopping || this._suspended) return;
13662
- if (this.rtpActivityWatchdog) clearTimeout(this.rtpActivityWatchdog);
13663
- this.rtpActivityWatchdog = setTimeout(() => {
13664
- this.onRtpActivityWatchdogExpired();
13665
- }, StreamBroker.RTP_ACTIVITY_TIMEOUT_MS);
13787
+ this.lastRtpActivityAt = Date.now();
13788
+ if (this.rtpActivitySweep !== void 0) return;
13789
+ this.rtpActivitySweep = setInterval(() => {
13790
+ this.sweepRtpActivity();
13791
+ }, StreamBroker.RTP_ACTIVITY_SWEEP_MS);
13792
+ this.rtpActivitySweep.unref?.();
13793
+ }
13794
+ /**
13795
+ * One sweep: declare the stall only once the full
13796
+ * {@link StreamBroker.RTP_ACTIVITY_TIMEOUT_MS} of silence has actually
13797
+ * elapsed since the last packet.
13798
+ */
13799
+ sweepRtpActivity() {
13800
+ if (this.lastRtpActivityAt === 0) return;
13801
+ if (Date.now() - this.lastRtpActivityAt < StreamBroker.RTP_ACTIVITY_TIMEOUT_MS) return;
13802
+ this.cancelRtpActivityWatchdog();
13803
+ this.onRtpActivityWatchdogExpired();
13666
13804
  }
13667
13805
  /** Cancel the rolling RTP activity watchdog. Idempotent. */
13668
13806
  cancelRtpActivityWatchdog() {
13669
- if (!this.rtpActivityWatchdog) return;
13670
- clearTimeout(this.rtpActivityWatchdog);
13671
- this.rtpActivityWatchdog = void 0;
13807
+ this.lastRtpActivityAt = 0;
13808
+ if (this.rtpActivitySweep === void 0) return;
13809
+ clearInterval(this.rtpActivitySweep);
13810
+ this.rtpActivitySweep = void 0;
13672
13811
  }
13673
13812
  /**
13674
13813
  * Activity watchdog expiry: source RTP went silent for longer than
@@ -13683,7 +13822,6 @@ var StreamBroker = class StreamBroker {
13683
13822
  * their own `PUSH_STALL_TIMEOUT_MS` path.
13684
13823
  */
13685
13824
  onRtpActivityWatchdogExpired() {
13686
- this.rtpActivityWatchdog = void 0;
13687
13825
  this.rtpActivityWatchdogFiredCount++;
13688
13826
  if (this.manualStop || this.stopping || this._suspended) return;
13689
13827
  const meta = {
@@ -29295,6 +29433,7 @@ var StreamBrokerAddon = class extends BaseAddon {
29295
29433
  }
29296
29434
  }
29297
29435
  async onInitialize() {
29436
+ this.ctx.logger.info("log channels declared", { meta: { channels: STREAM_BROKER_LOG_CHANNELS.map((c) => c.descriptor.name) } });
29298
29437
  this.logChannels = createLogChannelsProvider(this.ctx.logger.child("log-channels"));
29299
29438
  const logChannelsRegistration = {
29300
29439
  capability: logChannelsCapability,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.140",
3
+ "version": "1.2.142",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",