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