@camstack/addon-pipeline 1.2.363 → 1.2.365

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 (35) hide show
  1. package/dist/audio-analyzer/index.js +3 -3
  2. package/dist/audio-analyzer/index.mjs +3 -3
  3. package/dist/{custom-model-catalog-nx2TXAGS.js → custom-model-catalog-C34VqcPo.js} +2 -2
  4. package/dist/{custom-model-catalog-DHFPSu0E.mjs → custom-model-catalog-CtpK3FKL.mjs} +2 -2
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-BX9VTm12.mjs → dist-BshC0NZR.mjs} +411 -291
  8. package/dist/{dist-CGOb--t-.js → dist-i4kS_3O7.js} +411 -291
  9. package/dist/{model-catalogs-DHV48cdY.js → model-catalogs-Cy7mZRWh.js} +1 -1
  10. package/dist/{model-catalogs-DNL4ydAg.mjs → model-catalogs-DNx5ATgm.mjs} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-C8bbmV3c.mjs → node-Dqmrb990.mjs} +1 -1
  14. package/dist/{node-UBMRK8SK.js → node-TzgGsCvW.js} +1 -1
  15. package/dist/pipeline-runner/index.js +3 -3
  16. package/dist/pipeline-runner/index.mjs +3 -3
  17. package/dist/{process-memory-1BwIq2Ks.js → process-memory-DLCPFTGC.js} +1 -1
  18. package/dist/{process-memory-BHG2uCeq.mjs → process-memory-DyfrbFY4.mjs} +1 -1
  19. package/dist/recorder/index.js +2 -2
  20. package/dist/recorder/index.mjs +2 -2
  21. package/dist/{segment-demux-js-PkDP-xQg.js → segment-demux-js-C9AYdKkD.js} +1 -1
  22. package/dist/{segment-demux-js-BhnT__dA.mjs → segment-demux-js-COoPqx93.mjs} +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-vdmrukBk.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DVgnss_e.mjs} +2 -2
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DWI1BCfa.mjs +26 -0
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BUBmyDde.mjs +26 -0
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-D_DBY5wu.mjs → hostInit-ZvA7Cl_1.mjs} +2 -2
  30. package/dist/stream-broker/index.js +513 -174
  31. package/dist/stream-broker/index.mjs +513 -174
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/package.json +4 -2
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-ScPS8ZWM.mjs +0 -26
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Cq5NPjBX.mjs +0 -26
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $n as discriminatedUnion, An as BaseAddon, D as G711_SCALE_CORRECTION_DB, Dn as DeviceType, Dt as gifDeriveSpeed, En as DeviceFeature, Gn as maskUrlCredentials, Hn as isEvent, I as RATE_CONTROL_RELAXED, In as createEvent, J as addonWidgetsSourceCapability, Kn as nodePin, L as RATE_CONTROL_TIGHT, Mn as CamProfileSchema, Nt as loadContributionCapability, On as errMsg, Ot as gifFromMp4Args, Pt as logChannelsCapability, Qn as boolean, Rt as memoizePinnedFfmpeg, Sn as buildFfmpegArgs, Tn as isSoftwareDecode, Tt as ffmpegRate, Un as makeProfileBrokerId, W as RingBuffer, Wn as makeSourceBrokerId, Xn as _enum, Xt as rankStreams, Zn as array, _t as egressTranscodeSharingKey, ar as record, at as clipExportFailureOf, c as CLIP_STILL_REASON_CODE_HEADER, cr as EventCategory, ct as createHwAccelCache, et as batteryCapability, ft as deriveBatteryPresence, jn as CAM_PROFILE_ORDER, kn as BOOT_RECOVERY_BACKOFF_MS, lt as createLogChannelsProvider, mn as streamBrokerCapability, nr as number, o as BatteryStatusSchema, or as string, qn as parseProfileBrokerId, rn as resolveEgressDecodeHwAccel, rr as object, rt as cameraStreamsCapability, s as CLIP_BROKER_PACED_RATES, sr as union, tr as literal, ut as declareLogChannel, vn as webrtcSessionCapability, vt as egressTransportFromRequest, w as EncodeProfileSchema, wn as invocationFromEncodeProfile, yn as AUDIO_PRESETS, zn as expandAudioChunkToF32le } from "../dist-BX9VTm12.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-C8bbmV3c.mjs";
2
+ import { $n as discriminatedUnion, An as BaseAddon, D as G711_SCALE_CORRECTION_DB, Dn as DeviceType, Dt as gifDeriveSpeed, En as DeviceFeature, Gn as maskUrlCredentials, Hn as isEvent, I as RATE_CONTROL_RELAXED, In as createEvent, J as addonWidgetsSourceCapability, Kn as nodePin, L as RATE_CONTROL_TIGHT, Mn as CamProfileSchema, Nt as loadContributionCapability, On as errMsg, Ot as gifFromMp4Args, Pt as logChannelsCapability, Qn as boolean, Rt as memoizePinnedFfmpeg, Sn as buildFfmpegArgs, Tn as isSoftwareDecode, Tt as ffmpegRate, Un as makeProfileBrokerId, W as RingBuffer, Wn as makeSourceBrokerId, Xn as _enum, Xt as rankStreams, Zn as array, _t as egressTranscodeSharingKey, ar as record, at as clipExportFailureOf, c as CLIP_STILL_REASON_CODE_HEADER, cr as EventCategory, ct as createHwAccelCache, et as batteryCapability, ft as deriveBatteryPresence, jn as CAM_PROFILE_ORDER, kn as BOOT_RECOVERY_BACKOFF_MS, lt as createLogChannelsProvider, mn as streamBrokerCapability, nr as number, o as BatteryStatusSchema, or as string, qn as parseProfileBrokerId, rn as resolveEgressDecodeHwAccel, rr as object, rt as cameraStreamsCapability, s as CLIP_BROKER_PACED_RATES, sr as union, tr as literal, ut as declareLogChannel, vn as webrtcSessionCapability, vt as egressTransportFromRequest, w as EncodeProfileSchema, wn as invocationFromEncodeProfile, yn as AUDIO_PRESETS, zn as expandAudioChunkToF32le } from "../dist-BshC0NZR.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-Dqmrb990.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.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 { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-BhnT__dA.mjs";
7
+ import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-COoPqx93.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as os$1 from "node:os";
10
10
  import { networkInterfaces, tmpdir } from "node:os";
@@ -3081,6 +3081,68 @@ var RtpPacketizer = class {
3081
3081
  }
3082
3082
  };
3083
3083
  //#endregion
3084
+ //#region src/stream-broker/stream-broker/owned-buffer.ts
3085
+ /**
3086
+ * Buffers that enter a LONG-LIVED ring own their backing store.
3087
+ *
3088
+ * Node serves `Buffer.from(...)`, `Buffer.allocUnsafe(n)` and `Buffer.concat`
3089
+ * below `Buffer.poolSize >>> 1` (4 KiB) as views of shared 8 KiB pool slabs.
3090
+ * That is the right choice for the ingest path — a start code, a 4-byte
3091
+ * interleave header, a 1.3 KB RTP packet that is fanned out and forgotten
3092
+ * within a tick — and the wrong one for anything RETAINED: a 600-byte NAL kept
3093
+ * for 60 s in a clip ring pins its whole slab, and with it every short-lived
3094
+ * packet that happened to be allocated beside it. On the hub that held
3095
+ * 550–980 MB of ArrayBuffers for ~150–250 MB of payload, and the full GCs the
3096
+ * pinned slabs caused ran every 0.2–0.5 s at 40–65 % of a core.
3097
+ *
3098
+ * The ring byte caps counted payload bytes, so they never saw it. Both rings
3099
+ * now pass every entry through {@link detachFromPool} on the way in and
3100
+ * account it with {@link backingStoreBytes}, so the cap measures what the
3101
+ * entry costs. Short-lived traffic stays pooled.
3102
+ */
3103
+ /** True when `buf` is the whole of its ArrayBuffer — nothing else shares it. */
3104
+ function ownsBackingStore(buf) {
3105
+ return buf.byteOffset === 0 && buf.buffer.byteLength === buf.length;
3106
+ }
3107
+ /**
3108
+ * What retaining `buf` really costs: its own length when it owns its
3109
+ * ArrayBuffer, the whole shared slab when it is a view of one.
3110
+ */
3111
+ function backingStoreBytes(buf) {
3112
+ return ownsBackingStore(buf) ? buf.length : buf.buffer.byteLength;
3113
+ }
3114
+ /**
3115
+ * `buf` itself when it already owns its ArrayBuffer (decided by inspecting the
3116
+ * buffer, never by its size: whether a large `allocUnsafe` is pooled is a Node
3117
+ * runtime detail), else a copy that does. One copy, never two: a buffer that is
3118
+ * already unpooled is returned as-is.
3119
+ */
3120
+ function detachFromPool(buf) {
3121
+ if (ownsBackingStore(buf)) return buf;
3122
+ const owned = Buffer.allocUnsafeSlow(buf.length);
3123
+ buf.copy(owned);
3124
+ return owned;
3125
+ }
3126
+ /**
3127
+ * A copy of `src[start, end)` that OWNS its backing store — the shape a reader
3128
+ * hands out when the packet it cuts from its socket buffer is going to be
3129
+ * retained anyway.
3130
+ *
3131
+ * Every source video packet is: `captureRtpForPreBuffer` keeps it for the life
3132
+ * of a GOP, and the restreamer's keyframe cache keeps the keyframe ones. With
3133
+ * the readers cutting packets as `Buffer.from(subarray)` — a pooled view — the
3134
+ * ring then had to copy each one a second time to own it: two copies of every
3135
+ * packet on the ingest path (measured 15.1 + 14.8 MB/s on the hub's load).
3136
+ * Cutting the packet owned up front makes the ring's and the cache's
3137
+ * `detachFromPool` an identity, so the packet is copied once and shared by the
3138
+ * reader's fan-out, the ring, the cache and every session's write queue.
3139
+ */
3140
+ function copyOwned(src, start, end) {
3141
+ const owned = Buffer.allocUnsafeSlow(end - start);
3142
+ src.copy(owned, 0, start, end);
3143
+ return owned;
3144
+ }
3145
+ //#endregion
3084
3146
  //#region src/stream-broker/rtsp/sdp-builder.ts
3085
3147
  function buildSdp(options) {
3086
3148
  const { codec, parameterSets, streamName } = options;
@@ -3727,7 +3789,12 @@ var RtspRestreamer = class {
3727
3789
  * Forwarded directly to all connected RTSP sessions — zero re-packetization.
3728
3790
  *
3729
3791
  * @param rtpData Complete RTP packet (header + payload)
3730
- * @param isKeyframe True if this packet is part of a keyframe access unit
3792
+ * @param isKeyframe True if this packet is part of a keyframe ACCESS UNIT —
3793
+ * judged per picture by the broker's `RtpAccessUnitTracker`: every
3794
+ * fragment of a fragmented IDR and a prefix SEI inside the picture carry
3795
+ * it, not only the packet that completed an IRAP NAL. The keyframe cache
3796
+ * below collects exactly that run, and the transition into it is the
3797
+ * once-per-picture `keyframeAuStart` the sessions resume and count on.
3731
3798
  * @param isParameterSet True if this packet contains SPS/PPS/VPS
3732
3799
  */
3733
3800
  pushRtpPassthrough(rtpData, isKeyframe, isParameterSet) {
@@ -3743,14 +3810,14 @@ var RtspRestreamer = class {
3743
3810
  this.lastKeyframeRtpBytes = 0;
3744
3811
  }
3745
3812
  }
3813
+ if (this.collectingKeyframeRtp && !isKeyframe && !isParameterSet) this.collectingKeyframeRtp = false;
3746
3814
  if (this.collectingKeyframeRtp) {
3747
3815
  this.lastKeyframeRtpPackets.push({
3748
- data: Buffer.from(rtpData),
3816
+ data: detachFromPool(rtpData),
3749
3817
  marker: rtpPacket.marker
3750
3818
  });
3751
3819
  this.lastKeyframeRtpBytes += rtpData.length;
3752
- if (!isKeyframe && !isParameterSet) this.collectingKeyframeRtp = false;
3753
- else if (this.lastKeyframeRtpBytes > MAX_KEYFRAME_CACHE_BYTES) {
3820
+ if (this.lastKeyframeRtpBytes > MAX_KEYFRAME_CACHE_BYTES) {
3754
3821
  this.warnCacheCapExceeded("rtp", this.lastKeyframeRtpBytes, this.lastKeyframeRtpPackets.length);
3755
3822
  this.lastKeyframeRtpPackets = [];
3756
3823
  this.lastKeyframeRtpBytes = 0;
@@ -3763,17 +3830,16 @@ var RtspRestreamer = class {
3763
3830
  if (!session.isPlaying()) continue;
3764
3831
  this.pendingKeyframe.delete(session.getSessionId());
3765
3832
  }
3766
- const keyframeAu = isKeyframe || isParameterSet;
3767
3833
  for (const session of this.sessions.values()) {
3768
3834
  const sessionId = session.getSessionId();
3769
3835
  if (this.activeReplays.get(sessionId)?.enqueue({
3770
3836
  packet: rtpPacket,
3771
3837
  prime: false,
3772
- keyframeAuStart: keyframeAu
3838
+ keyframeAuStart
3773
3839
  })) continue;
3774
3840
  if (this.pendingKeyframe.has(sessionId)) continue;
3775
3841
  if (!session.isPlaying()) continue;
3776
- this.recordLiveOutcome(session.sendRtp(rtpPacket, false, keyframeAu));
3842
+ this.recordLiveOutcome(session.sendRtp(rtpPacket, false, keyframeAuStart));
3777
3843
  }
3778
3844
  }
3779
3845
  /**
@@ -4349,6 +4415,19 @@ var RtspSession = class {
4349
4415
  lastRtpAt = 0;
4350
4416
  /** Total video RTP bytes written to the socket. */
4351
4417
  bytesSent = 0;
4418
+ /**
4419
+ * The socket is corked for the access unit in progress — see
4420
+ * {@link writeInterleaved}. Cleared by the packet that ends the picture (RTP
4421
+ * marker, or any audio packet) or by the next-tick safety net, whichever
4422
+ * comes first.
4423
+ */
4424
+ accessUnitCorked = false;
4425
+ /** Flush the access unit in progress. Idempotent: the marker and the safety net both call it. */
4426
+ uncorkAccessUnit = () => {
4427
+ if (!this.accessUnitCorked) return;
4428
+ this.accessUnitCorked = false;
4429
+ this.socket.uncork();
4430
+ };
4352
4431
  /** Client `User-Agent` (from the first request that carries one). Lets the
4353
4432
  * broker panel identify a consumer by purpose — e.g. the recorder sets
4354
4433
  * `-user_agent recorder` on its RTSP pull — instead of a bare address. */
@@ -4596,7 +4675,7 @@ var RtspSession = class {
4596
4675
  return "dropped-backlog";
4597
4676
  }
4598
4677
  }
4599
- this.writeInterleaved(this.tracks[0].rtpChannel, packet.data);
4678
+ this.writeInterleaved(this.tracks[0].rtpChannel, packet.data, packet.marker);
4600
4679
  this.missedKeyframes = 0;
4601
4680
  return "written";
4602
4681
  }
@@ -4605,7 +4684,7 @@ var RtspSession = class {
4605
4684
  if (!this.playing || this.closed || this.muted) return "not-playing";
4606
4685
  if (this.tracks.length < 2) return "not-playing";
4607
4686
  if (this.socket.writableLength > 2097152) return "dropped-backlog";
4608
- this.writeInterleaved(this.tracks[1].rtpChannel, packet.data);
4687
+ this.writeInterleaved(this.tracks[1].rtpChannel, packet.data, true);
4609
4688
  return "written";
4610
4689
  }
4611
4690
  /**
@@ -4627,14 +4706,13 @@ var RtspSession = class {
4627
4706
  * ## Why sharing the payload is safe, and what would make it unsafe
4628
4707
  *
4629
4708
  * The payload buffer handed here is a private allocation per packet —
4630
- * `Buffer.from(readBuffer.subarray(...))` in `rtsp-client.ts`'s `onData`, for
4631
- * video and audio alike — never a view of the reader's socket buffer, which
4632
- * advances on the next read. Nothing in the broker writes into a captured
4633
- * packet: every mutation in this path targets a freshly allocated buffer.
4634
- * (The broker's `rtpRing` keeps its OWN copy of a pooled packet —
4635
- * `detachFromPool`, so a ring entry never pins a shared pool slab — so the
4636
- * ring no longer vouches for THIS object's lifetime, and it does not need
4637
- * to: a pending write holds its own reference, and the rule below is about
4709
+ * `copyOwned(readBuffer, …)` in the readers' `onData`, for video and audio
4710
+ * alike — never a view of the reader's socket buffer, which advances on the
4711
+ * next read. Nothing in the broker writes into a captured packet: every
4712
+ * mutation in this path targets a freshly allocated buffer. (The same object
4713
+ * is what the broker's `rtpRing` and the restreamer's keyframe cache retain —
4714
+ * it owns its backing store, so `detachFromPool` keeps it as is — and that is
4715
+ * fine: a pending write holds its own reference, and the rule below is about
4638
4716
  * mutation, not retention.)
4639
4717
  *
4640
4718
  * The rule to keep: **`socket.write` forbids MUTATION before the write
@@ -4644,18 +4722,36 @@ var RtspSession = class {
4644
4722
  * queued. Four bytes from the Buffer pool is a pointer bump; the copy this
4645
4723
  * replaces was ~1.4 KB.
4646
4724
  *
4647
- * `cork`/`uncork` keeps the pair a single `writev` syscall, so removing the
4648
- * copy does not buy an extra write syscall per packet.
4725
+ * ## One cork per ACCESS UNIT, not per packet
4726
+ *
4727
+ * The pair used to be corked and uncorked on its own: one `writev` per RTP
4728
+ * packet per session, so a 4K keyframe fragmented into a hundred packets and
4729
+ * fanned out to three sessions was three hundred syscalls for one picture no
4730
+ * decoder can use before it is whole. The socket is corked on the first write
4731
+ * of an access unit and uncorked by the packet that ENDS it
4732
+ * (`endOfAccessUnit`: the RTP marker on video, every audio packet), so the
4733
+ * picture leaves in one `writev`. A `process.nextTick` safety net uncorks
4734
+ * whatever is still open before the tick yields to I/O — a marker-less camera
4735
+ * or a lost marker degrades to one `writev` per tick, never to a socket held
4736
+ * corked across a read.
4737
+ *
4738
+ * Backpressure sees through the cork: `writableLength` counts corked bytes,
4739
+ * so the `MAX_WRITE_BUFFER` gate, the latch and the missed-keyframe close
4740
+ * (D447) judge exactly what they judged per packet.
4649
4741
  */
4650
- writeInterleaved(channel, payload) {
4742
+ writeInterleaved(channel, payload, endOfAccessUnit) {
4651
4743
  const header = Buffer.allocUnsafe(4);
4652
4744
  header[0] = 36;
4653
4745
  header[1] = channel;
4654
4746
  header.writeUInt16BE(payload.length, 2);
4655
- this.socket.cork();
4747
+ if (!this.accessUnitCorked) {
4748
+ this.accessUnitCorked = true;
4749
+ this.socket.cork();
4750
+ process.nextTick(this.uncorkAccessUnit);
4751
+ }
4656
4752
  this.socket.write(header);
4657
4753
  this.socket.write(payload);
4658
- this.socket.uncork();
4754
+ if (endOfAccessUnit) this.uncorkAccessUnit();
4659
4755
  this.lastRtpAt = Date.now();
4660
4756
  this.bytesSent += 4 + payload.length;
4661
4757
  }
@@ -10079,7 +10175,7 @@ var Rfc4571Reader = class {
10079
10175
  while (this.readBuffer.length >= 2) {
10080
10176
  const length = this.readBuffer.readUInt16BE(0);
10081
10177
  if (this.readBuffer.length < 2 + length) return;
10082
- const rtpPacket = Buffer.from(this.readBuffer.subarray(2, 2 + length));
10178
+ const rtpPacket = copyOwned(this.readBuffer, 2, 2 + length);
10083
10179
  this.readBuffer = this.readBuffer.subarray(2 + length);
10084
10180
  this.dispatchRtp(rtpPacket);
10085
10181
  }
@@ -10124,12 +10220,109 @@ function allocAnnexBNal(byteLength) {
10124
10220
  buf[3] = 1;
10125
10221
  return buf;
10126
10222
  }
10223
+ var NO_PAYLOAD_CLASS = {
10224
+ keyframe: false,
10225
+ parameterSet: false
10226
+ };
10227
+ function isH265IrapType(type) {
10228
+ return type >= H265_NAL_IRAP_MIN && type <= H265_NAL_IRAP_MAX;
10229
+ }
10230
+ function isH265ParameterSetType(type) {
10231
+ return type === H265_NAL_VPS || type === H265_NAL_SPS || type === H265_NAL_PPS;
10232
+ }
10127
10233
  /** Total byte length of the reassembly fragments, without allocating a closure. */
10128
10234
  function totalFragmentBytes(fragments) {
10129
10235
  let total = 0;
10130
10236
  for (const fragment of fragments) total += fragment.length;
10131
10237
  return total;
10132
10238
  }
10239
+ /** H.264: single NAL by its type; STAP-A by every aggregated type; FU-A by the FU header. */
10240
+ function classifyH264Payload(payload) {
10241
+ const nalType = payload[0] & 31;
10242
+ if (nalType === H264_NAL_STAP_A) {
10243
+ let keyframe = false;
10244
+ let parameterSet = false;
10245
+ let offset = 1;
10246
+ while (offset + 2 <= payload.length) {
10247
+ const nalSize = payload.readUInt16BE(offset);
10248
+ offset += 2;
10249
+ if (nalSize === 0 || offset + nalSize > payload.length) break;
10250
+ const type = payload[offset] & 31;
10251
+ keyframe ||= type === H264_NAL_IDR;
10252
+ parameterSet ||= type === H264_NAL_SPS || type === H264_NAL_PPS;
10253
+ offset += nalSize;
10254
+ }
10255
+ return {
10256
+ keyframe,
10257
+ parameterSet
10258
+ };
10259
+ }
10260
+ if (nalType === 28) {
10261
+ if (payload.length < 2) return NO_PAYLOAD_CLASS;
10262
+ return {
10263
+ keyframe: (payload[1] & 31) === H264_NAL_IDR,
10264
+ parameterSet: false
10265
+ };
10266
+ }
10267
+ return {
10268
+ keyframe: nalType === H264_NAL_IDR,
10269
+ parameterSet: nalType === H264_NAL_SPS || nalType === H264_NAL_PPS
10270
+ };
10271
+ }
10272
+ /** H.265: single NAL by its type; AP by every aggregated type; FU by the FU header. */
10273
+ function classifyH265Payload(payload) {
10274
+ if (payload.length < 2) return NO_PAYLOAD_CLASS;
10275
+ const nalType = payload[0] >> 1 & 63;
10276
+ if (nalType === H265_NAL_AP) {
10277
+ let keyframe = false;
10278
+ let parameterSet = false;
10279
+ let offset = 2;
10280
+ while (offset + 2 <= payload.length) {
10281
+ const nalSize = payload.readUInt16BE(offset);
10282
+ offset += 2;
10283
+ if (nalSize === 0 || offset + nalSize > payload.length) break;
10284
+ const type = payload[offset] >> 1 & 63;
10285
+ keyframe ||= isH265IrapType(type);
10286
+ parameterSet ||= isH265ParameterSetType(type);
10287
+ offset += nalSize;
10288
+ }
10289
+ return {
10290
+ keyframe,
10291
+ parameterSet
10292
+ };
10293
+ }
10294
+ if (nalType === 49) {
10295
+ if (payload.length < 3) return NO_PAYLOAD_CLASS;
10296
+ return {
10297
+ keyframe: isH265IrapType(payload[2] & 63),
10298
+ parameterSet: false
10299
+ };
10300
+ }
10301
+ return {
10302
+ keyframe: isH265IrapType(nalType),
10303
+ parameterSet: isH265ParameterSetType(nalType)
10304
+ };
10305
+ }
10306
+ /**
10307
+ * Classify one raw RTP packet (12-byte header + payload) by what it carries —
10308
+ * the per-packet half of {@link RtpDepacketizer.processPacket}, without the
10309
+ * reassembly half. Pure: reads the headers, allocates nothing, keeps no state.
10310
+ * `null` for a packet too short to be RTP with a payload.
10311
+ */
10312
+ function classifyRtpPacket(codec, rtpData) {
10313
+ if (rtpData.length < 12) return null;
10314
+ const marker = (rtpData[1] & 128) !== 0;
10315
+ const timestamp = rtpData.readUInt32BE(4);
10316
+ const payload = rtpData.subarray(12);
10317
+ if (payload.length === 0) return null;
10318
+ const carried = codec === "h264" ? classifyH264Payload(payload) : classifyH265Payload(payload);
10319
+ return {
10320
+ timestamp,
10321
+ marker,
10322
+ keyframe: carried.keyframe,
10323
+ parameterSet: carried.parameterSet
10324
+ };
10325
+ }
10133
10326
  /**
10134
10327
  * RTP → Annex-B NAL depacketizer.
10135
10328
  *
@@ -10169,16 +10362,27 @@ var RtpDepacketizer = class {
10169
10362
  }
10170
10363
  /**
10171
10364
  * Process a raw RTP packet (including 12-byte RTP header).
10172
- * Emits zero or more complete NAL units via the callback.
10365
+ * Emits zero or more complete NAL units via the callback and returns what
10366
+ * THIS packet carried ({@link RtpPacketClass}) — `null` for a packet too
10367
+ * short to be RTP with a payload.
10173
10368
  */
10174
10369
  processPacket(rtpData) {
10175
- if (rtpData.length < 12) return;
10176
- const marker = (rtpData[1] & 128) !== 0;
10177
- const timestamp = rtpData.readUInt32BE(4);
10370
+ const carried = this.classifyPacket(rtpData);
10371
+ if (carried === null) return null;
10178
10372
  const payload = rtpData.subarray(12);
10179
- if (payload.length === 0) return;
10180
- if (this.codec === "h264") this.processH264(payload, timestamp, marker);
10181
- else this.processH265(payload, timestamp, marker);
10373
+ if (this.codec === "h264") this.processH264(payload, carried.timestamp, carried.marker);
10374
+ else this.processH265(payload, carried.timestamp, carried.marker);
10375
+ return carried;
10376
+ }
10377
+ /**
10378
+ * What this packet CARRIES, and nothing else: no NAL is reassembled, no buffer
10379
+ * allocated, no reassembly state touched. The same verdict `processPacket`
10380
+ * returns, for a broker whose Annex-B plane has no consumer — the source-RTP
10381
+ * ring, the restreamer's keyframe cache and the stats need exactly this and
10382
+ * none of the copies.
10383
+ */
10384
+ classifyPacket(rtpData) {
10385
+ return classifyRtpPacket(this.codec, rtpData);
10182
10386
  }
10183
10387
  processH264(payload, timestamp, marker) {
10184
10388
  const nalType = payload[0] & 31;
@@ -10324,6 +10528,140 @@ var RtpDepacketizer = class {
10324
10528
  }
10325
10529
  };
10326
10530
  //#endregion
10531
+ //#region src/stream-broker/rtsp/rtp-access-unit-tracker.ts
10532
+ /**
10533
+ * Tracks access-unit membership across the source RTP packets of ONE stream.
10534
+ *
10535
+ * The RTSP restreamer forwards source RTP untouched and must know, per
10536
+ * packet, whether it is part of a keyframe picture: that is what its keyframe
10537
+ * cache collects and what a backlog-latched session resumes on. The
10538
+ * depacketizer can only say so about a NAL that COMPLETED, so the fragments of
10539
+ * a fragmented IDR before the last one, and a prefix SEI between the parameter
10540
+ * sets and the slice, were reported as plain packets: the cache latch closed
10541
+ * on them and re-opened on the final fragment, and a backlogged session
10542
+ * counted every IRAP slice as a missed keyframe. Here a packet is classified
10543
+ * by what it carries ({@link RtpPacketClass}) and the verdict is carried
10544
+ * across the whole picture, bounded by the RTP timestamp and marker bit.
10545
+ *
10546
+ * A parameter-set-only run is not a picture: a camera that stamps SPS/PPS
10547
+ * with the previous timestamp still announces ONE keyframe, so the keyframe
10548
+ * verdict carries over into the slice that follows instead of starting twice.
10549
+ */
10550
+ var RtpAccessUnitTracker = class {
10551
+ timestamp = -1;
10552
+ previousMarker = true;
10553
+ keyframeAu = false;
10554
+ /** The picture in progress has received something other than a parameter set. */
10555
+ pictureSeen = false;
10556
+ classify(packet) {
10557
+ const auStart = this.previousMarker || packet.timestamp !== this.timestamp;
10558
+ if (auStart && this.pictureSeen) this.keyframeAu = false;
10559
+ if (auStart) this.pictureSeen = false;
10560
+ this.timestamp = packet.timestamp;
10561
+ this.previousMarker = packet.marker;
10562
+ const keyframeContent = packet.keyframe || packet.parameterSet;
10563
+ const keyframeAuStart = keyframeContent && !this.keyframeAu;
10564
+ if (keyframeContent) this.keyframeAu = true;
10565
+ const pictureStart = !packet.parameterSet && !this.pictureSeen;
10566
+ if (!packet.parameterSet) this.pictureSeen = true;
10567
+ return {
10568
+ auStart,
10569
+ keyframeAu: this.keyframeAu,
10570
+ keyframeAuStart,
10571
+ pictureStart,
10572
+ parameterSet: packet.parameterSet
10573
+ };
10574
+ }
10575
+ /** A redial re-bases the clock; nothing carries over. */
10576
+ reset() {
10577
+ this.timestamp = -1;
10578
+ this.previousMarker = true;
10579
+ this.keyframeAu = false;
10580
+ this.pictureSeen = false;
10581
+ }
10582
+ };
10583
+ //#endregion
10584
+ //#region src/stream-broker/stream-broker/access-unit-assembler.ts
10585
+ /**
10586
+ * Groups depacketized NALs into ACCESS UNITS — one per picture.
10587
+ *
10588
+ * The broker's `EncodedPacket` is one access unit, and every consumer judges it
10589
+ * that way: `keyframe` gates where a clip ring widens to, where a late joiner
10590
+ * can start, how the pre-roll window is cut; the stats count it as one picture
10591
+ * and time the GOP from one `keyframe` to the next. A multi-slice camera (a
10592
+ * Hikvision G2 main stream is 3 slices per picture) used to hand each slice —
10593
+ * and each prefix SEI — down as its own "frame": 75 input fps on a 25 fps
10594
+ * camera, an 8–34 ms "GOP", a clip ring that widened to the LAST IRAP slice
10595
+ * (a third of the picture, without its parameter sets), and a prefix SEI that
10596
+ * inherited the welded VPS/SPS/PPS instead of the IDR.
10597
+ *
10598
+ * Boundary rule (RFC 3550 / 6184 / 7798): the RTP marker bit closes the
10599
+ * picture it ends — flushed on the spot, so a single-slice stream pays no
10600
+ * latency — and a timestamp change closes the one before it, for cameras that
10601
+ * never set the marker. Parameter sets are held and welded onto the picture
10602
+ * that follows them, so a keyframe access unit is self-contained
10603
+ * (VPS/SPS/PPS + IDR) exactly as before; a prefix SEI stays inside its
10604
+ * picture and is never a frame of its own.
10605
+ */
10606
+ var AccessUnitAssembler = class {
10607
+ onAccessUnit;
10608
+ /** Parameter sets seen since the last picture closed; welded onto the next one. */
10609
+ pendingParamNals = [];
10610
+ /** NALs of the picture in progress (parameter sets first when welded). */
10611
+ pictureNals = [];
10612
+ pictureTimestamp = 0;
10613
+ pictureKeyframe = false;
10614
+ constructor(onAccessUnit) {
10615
+ this.onAccessUnit = onAccessUnit;
10616
+ }
10617
+ push(depacketized) {
10618
+ const { annexB, keyframe, parameterSet, timestamp, marker } = depacketized;
10619
+ if (this.pictureNals.length > 0 && timestamp !== this.pictureTimestamp) this.flush();
10620
+ if (parameterSet) {
10621
+ this.pendingParamNals.push(annexB);
10622
+ return;
10623
+ }
10624
+ if (this.pictureNals.length === 0) {
10625
+ this.pictureTimestamp = timestamp;
10626
+ this.pictureKeyframe = false;
10627
+ }
10628
+ if (this.pendingParamNals.length > 0) {
10629
+ for (const paramNal of this.pendingParamNals) this.pictureNals.push(paramNal);
10630
+ this.pendingParamNals = [];
10631
+ this.pictureKeyframe = true;
10632
+ }
10633
+ this.pictureNals.push(annexB);
10634
+ if (keyframe) this.pictureKeyframe = true;
10635
+ if (marker) this.flush();
10636
+ }
10637
+ /**
10638
+ * Emit the picture in progress, if any. Called on the marker bit, on a
10639
+ * timestamp change, and by the broker when the stream stops or redials so
10640
+ * the last picture of a marker-less stream is not lost.
10641
+ */
10642
+ flush() {
10643
+ if (this.pictureNals.length === 0) return;
10644
+ const nals = this.pictureNals;
10645
+ const timestamp = this.pictureTimestamp;
10646
+ const keyframe = this.pictureKeyframe;
10647
+ this.pictureNals = [];
10648
+ this.pictureKeyframe = false;
10649
+ const annexB = nals.length === 1 ? nals[0] : Buffer.concat(nals);
10650
+ this.onAccessUnit({
10651
+ annexB,
10652
+ timestamp,
10653
+ keyframe
10654
+ });
10655
+ }
10656
+ /** Drop everything held — a redial re-bases the clock, nothing carries over. */
10657
+ reset() {
10658
+ this.pendingParamNals = [];
10659
+ this.pictureNals = [];
10660
+ this.pictureTimestamp = 0;
10661
+ this.pictureKeyframe = false;
10662
+ }
10663
+ };
10664
+ //#endregion
10327
10665
  //#region src/stream-broker/rtsp/rtsp-client.ts
10328
10666
  var DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
10329
10667
  var DEFAULT_KEEPALIVE_INTERVAL_MS = 3e4;
@@ -10556,7 +10894,7 @@ var NativeRtspClient = class {
10556
10894
  const channel = this.readBuffer[1];
10557
10895
  const length = this.readBuffer.readUInt16BE(2);
10558
10896
  if (this.readBuffer.length < 4 + length) return;
10559
- const payload = Buffer.from(this.readBuffer.subarray(4, 4 + length));
10897
+ const payload = copyOwned(this.readBuffer, 4, 4 + length);
10560
10898
  this.readBuffer = this.readBuffer.subarray(4 + length);
10561
10899
  this.handleInterleavedPacket(channel, payload);
10562
10900
  } else {
@@ -11292,49 +11630,6 @@ var AudioCodecSession = class {
11292
11630
  }
11293
11631
  };
11294
11632
  //#endregion
11295
- //#region src/stream-broker/stream-broker/owned-buffer.ts
11296
- /**
11297
- * Buffers that enter a LONG-LIVED ring own their backing store.
11298
- *
11299
- * Node serves `Buffer.from(...)`, `Buffer.allocUnsafe(n)` and `Buffer.concat`
11300
- * below `Buffer.poolSize >>> 1` (4 KiB) as views of shared 8 KiB pool slabs.
11301
- * That is the right choice for the ingest path — a start code, a 4-byte
11302
- * interleave header, a 1.3 KB RTP packet that is fanned out and forgotten
11303
- * within a tick — and the wrong one for anything RETAINED: a 600-byte NAL kept
11304
- * for 60 s in a clip ring pins its whole slab, and with it every short-lived
11305
- * packet that happened to be allocated beside it. On the hub that held
11306
- * 550–980 MB of ArrayBuffers for ~150–250 MB of payload, and the full GCs the
11307
- * pinned slabs caused ran every 0.2–0.5 s at 40–65 % of a core.
11308
- *
11309
- * The ring byte caps counted payload bytes, so they never saw it. Both rings
11310
- * now pass every entry through {@link detachFromPool} on the way in and
11311
- * account it with {@link backingStoreBytes}, so the cap measures what the
11312
- * entry costs. Short-lived traffic stays pooled.
11313
- */
11314
- /** True when `buf` is the whole of its ArrayBuffer — nothing else shares it. */
11315
- function ownsBackingStore(buf) {
11316
- return buf.byteOffset === 0 && buf.buffer.byteLength === buf.length;
11317
- }
11318
- /**
11319
- * What retaining `buf` really costs: its own length when it owns its
11320
- * ArrayBuffer, the whole shared slab when it is a view of one.
11321
- */
11322
- function backingStoreBytes(buf) {
11323
- return ownsBackingStore(buf) ? buf.length : buf.buffer.byteLength;
11324
- }
11325
- /**
11326
- * `buf` itself when it already owns its ArrayBuffer (decided by inspecting the
11327
- * buffer, never by its size: whether a large `allocUnsafe` is pooled is a Node
11328
- * runtime detail), else a copy that does. One copy, never two: a buffer that is
11329
- * already unpooled is returned as-is.
11330
- */
11331
- function detachFromPool(buf) {
11332
- if (ownsBackingStore(buf)) return buf;
11333
- const owned = Buffer.allocUnsafeSlow(buf.length);
11334
- buf.copy(owned);
11335
- return owned;
11336
- }
11337
- //#endregion
11338
11633
  //#region src/stream-broker/stream-broker/encoded-ring-buffer.ts
11339
11634
  /**
11340
11635
  * Time-based ring buffer for encoded video packets.
@@ -12274,9 +12569,6 @@ var StreamBroker = class StreamBroker {
12274
12569
  */
12275
12570
  ffmpegBinaryPathSource = () => null;
12276
12571
  rtpDepacketizer = null;
12277
- /** Flag-only depacketizer for the `push-rtp` source-RTP pre-buffer path
12278
- * (sets keyframe/param-set flags without re-emitting AnnexB packets). */
12279
- pushRtpFlagDepacketizer = null;
12280
12572
  audioRtpDecoder = null;
12281
12573
  audioCodecSession = null;
12282
12574
  /**
@@ -12644,10 +12936,24 @@ var StreamBroker = class StreamBroker {
12644
12936
  * log alongside `audioRtpSeen`, so a wake-up cycle that brings audio
12645
12937
  * back but never video is observable from the structured logs. */
12646
12938
  videoRtpSeen = 0;
12647
- /** Tracking flags set synchronously by the RTP depacketizer callback. */
12939
+ /**
12940
+ * What the source RTP packet being handled CARRIES (keyframe / parameter-set
12941
+ * content, read from its payload headers by `classifyRtpPacket`) — the flags
12942
+ * `captureRtpForPreBuffer` reads. Per packet, not per completed NAL: every
12943
+ * fragment of a fragmented IDR carries them, and the ring marks one keyframe
12944
+ * AU for the lot.
12945
+ */
12648
12946
  _lastNalKeyframe = false;
12649
12947
  _lastNalParamSet = false;
12650
12948
  /**
12949
+ * Whether the Annex-B plane (NAL reassembly + access-unit assembly →
12950
+ * `EncodedPacket`) is being BUILT for this stream. False while nothing
12951
+ * consumes it: the source-RTP path then classifies each packet and copies
12952
+ * nothing. Armed at the next keyframe access unit once a consumer attaches
12953
+ * (`needsAnnexBPlane`), so the first packet it emits is decodable.
12954
+ */
12955
+ annexBPlaneActive = false;
12956
+ /**
12651
12957
  * Source-RTP pre-buffer: a ring of raw source RTP packets trimmed to
12652
12958
  * begin at the most recent keyframe access unit — i.e. the current GOP so
12653
12959
  * far. A late-joining WebRTC viewer on the repacketizer path replays this
@@ -13149,7 +13455,7 @@ var StreamBroker = class StreamBroker {
13149
13455
  this.rtspRestreamer.destroy();
13150
13456
  this.preBuffer.clear();
13151
13457
  this.clipBuffer?.clear();
13152
- this.pendingParamNals = [];
13458
+ this.annexBAssembler.reset();
13153
13459
  this.encodedCallbacks.clear();
13154
13460
  this.audioChunkPlane.dispose();
13155
13461
  }
@@ -13612,7 +13918,7 @@ var StreamBroker = class StreamBroker {
13612
13918
  this.reconcilePrestreamWindow();
13613
13919
  this.preBuffer.clear();
13614
13920
  this.clipBuffer?.clear();
13615
- this.pendingParamNals = [];
13921
+ this.annexBAssembler.reset();
13616
13922
  this.rtspRestreamer.resetStreamState();
13617
13923
  this.totalBytes = 0;
13618
13924
  this.bytesInWindow = 0;
@@ -13723,26 +14029,7 @@ var StreamBroker = class StreamBroker {
13723
14029
  return;
13724
14030
  }
13725
14031
  }
13726
- if (packet.type === "video") {
13727
- this.packetCount++;
13728
- this.totalBytes += packet.data.length;
13729
- this.bytesInWindow += packet.data.length;
13730
- this.packetsInWindow++;
13731
- this.lastPacketAt = Date.now();
13732
- const now = Date.now();
13733
- const windowMs = now - this.windowStartMs;
13734
- if (windowMs >= 2e3) {
13735
- this.bitrateKbps = Math.round(this.bytesInWindow * 8 / windowMs);
13736
- this.encodedInputFps = this.packetsInWindow / windowMs * 1e3;
13737
- this.bytesInWindow = 0;
13738
- this.packetsInWindow = 0;
13739
- this.windowStartMs = now;
13740
- }
13741
- if (packet.keyframe) {
13742
- if (this.lastKeyframeMs > 0) this.idrIntervalMs = now - this.lastKeyframeMs;
13743
- this.lastKeyframeMs = now;
13744
- }
13745
- }
14032
+ if (packet.type === "video" && this.rtpDepacketizer === null) this.recordVideoStats(packet.data.length, true, packet.keyframe);
13746
14033
  if (packet.type === "video" && !this.isRtpSource()) this.preBuffer.push(packet);
13747
14034
  if (packet.type === "video" && this.clipBuffer && packet.isPlaceholder !== true) this.clipBuffer.push(packet);
13748
14035
  this.fanOutEncoded(packet);
@@ -13933,8 +14220,8 @@ var StreamBroker = class StreamBroker {
13933
14220
  index: auStart,
13934
14221
  ts: auPacket.readUInt32BE(4)
13935
14222
  });
14223
+ this.trimRtpRingToPreRollWindow();
13936
14224
  }
13937
- this.trimRtpRingToPreRollWindow();
13938
14225
  this.rtpRingHasKeyframe = true;
13939
14226
  }
13940
14227
  this.rtpRingPrevMarker = marker;
@@ -14203,10 +14490,10 @@ var StreamBroker = class StreamBroker {
14203
14490
  *
14204
14491
  * `push-rtp` providers deliver AnnexB-equivalents separately via
14205
14492
  * `pushEncodedPacket()` (see `IStreamBroker` contract) — so this path
14206
- * does NOT re-emit EncodedPackets; it only derives the per-packet
14207
- * keyframe/param-set flags the pre-buffer capture needs (via a
14208
- * flag-only depacketizer that never touches the AnnexB plane), keeping
14209
- * the AnnexB consumers (pipe, restreamer) single-fed by the provider.
14493
+ * does NOT re-emit EncodedPackets; it only classifies each packet for the
14494
+ * keyframe/param-set flags the pre-buffer capture needs
14495
+ * (`classifyRtpPacket`, no reassembly), keeping the AnnexB consumers
14496
+ * (pipe, restreamer) single-fed by the provider.
14210
14497
  *
14211
14498
  * The pre-buffer is captured even with zero subscribers attached — a
14212
14499
  * viewer that connects mid-GOP must be able to replay the GOP that
@@ -14216,9 +14503,9 @@ var StreamBroker = class StreamBroker {
14216
14503
  pushVideoRtp(rtpData) {
14217
14504
  if (this.stopping) return;
14218
14505
  this.kickRtpActivityWatchdog();
14219
- this._lastNalKeyframe = false;
14220
- this._lastNalParamSet = false;
14221
- this.ensurePushRtpFlagDepacketizer().processPacket(rtpData);
14506
+ const carried = classifyRtpPacket(this.detectedCodec === "h265" ? "h265" : "h264", rtpData);
14507
+ this._lastNalKeyframe = carried?.keyframe ?? false;
14508
+ this._lastNalParamSet = carried?.parameterSet ?? false;
14222
14509
  this.captureRtpForPreBuffer(rtpData);
14223
14510
  if (this.rtpVideoCallbacks.size === 0) return;
14224
14511
  for (const [cb, entry] of this.rtpVideoCallbacks) try {
@@ -14228,27 +14515,6 @@ var StreamBroker = class StreamBroker {
14228
14515
  this.logger?.warn("rtp video subscriber threw", { meta: { error: errMsg(err) } });
14229
14516
  }
14230
14517
  }
14231
- /**
14232
- * Lazily build a flag-only RTP depacketizer for the `push-rtp` source
14233
- * path. Unlike the native reader's depacketizer (which emits AnnexB
14234
- * EncodedPackets via `handleDepacketizedNal`), this callback ONLY sets
14235
- * `_lastNalKeyframe`/`_lastNalParamSet` — the push provider owns the
14236
- * AnnexB plane through `pushEncodedPacket`, so re-emitting here would
14237
- * double-feed the pipe/restreamer. push-rtp has no SDP `onVideoTrack`,
14238
- * so the codec comes from the source declaration (`detectedCodec`, seeded
14239
- * in `start()`). Dropped on teardown (`resetRtpPipeline`) so a redial
14240
- * starts with clean FU reassembly state.
14241
- */
14242
- ensurePushRtpFlagDepacketizer() {
14243
- if (!this.pushRtpFlagDepacketizer) {
14244
- const codec = this.detectedCodec === "h265" ? "h265" : "h264";
14245
- this.pushRtpFlagDepacketizer = new RtpDepacketizer(codec, ({ keyframe, parameterSet }) => {
14246
- if (keyframe) this._lastNalKeyframe = true;
14247
- if (parameterSet) this._lastNalParamSet = true;
14248
- });
14249
- }
14250
- return this.pushRtpFlagDepacketizer;
14251
- }
14252
14518
  /** Toggle the per-device streaming debug flag. */
14253
14519
  setStreamingDebug(enabled) {
14254
14520
  this.streamingDebug = enabled;
@@ -14691,10 +14957,18 @@ var StreamBroker = class StreamBroker {
14691
14957
  seq: this.videoRtpSeen,
14692
14958
  bytes: rtpData.length
14693
14959
  } });
14694
- this._lastNalKeyframe = false;
14695
- this._lastNalParamSet = false;
14696
- this.rtpDepacketizer?.processPacket(rtpData);
14697
- this.rtspRestreamer.pushRtpPassthrough(rtpData, this._lastNalKeyframe, this._lastNalParamSet);
14960
+ const wantPlane = this.needsAnnexBPlane();
14961
+ if (!wantPlane && this.annexBPlaneActive) this.idleAnnexBPlane();
14962
+ const carried = this.annexBPlaneActive ? this.rtpDepacketizer?.processPacket(rtpData) ?? null : this.rtpDepacketizer?.classifyPacket(rtpData) ?? null;
14963
+ this._lastNalKeyframe = carried?.keyframe ?? false;
14964
+ this._lastNalParamSet = carried?.parameterSet ?? false;
14965
+ const auFlags = carried ? this.rtpAuTracker.classify(carried) : null;
14966
+ if (wantPlane && !this.annexBPlaneActive && auFlags?.keyframeAuStart === true) {
14967
+ this.armAnnexBPlane();
14968
+ this.rtpDepacketizer?.processPacket(rtpData);
14969
+ }
14970
+ if (auFlags) this.recordVideoStats(rtpData.length, auFlags.pictureStart, auFlags.keyframeAu);
14971
+ this.rtspRestreamer.pushRtpPassthrough(rtpData, auFlags?.keyframeAu ?? false, auFlags?.parameterSet ?? false);
14698
14972
  this.captureRtpForPreBuffer(rtpData);
14699
14973
  if (this.rtpVideoCallbacks.size > 0) for (const [cb, entry] of this.rtpVideoCallbacks) try {
14700
14974
  cb(rtpData);
@@ -14890,51 +15164,116 @@ var StreamBroker = class StreamBroker {
14890
15164
  * RTSP restreamer's pushRtpPassthrough.
14891
15165
  */
14892
15166
  /**
14893
- * Pending parameter-set NALs (SPS/PPS for H.264, VPS/SPS/PPS for H.265).
14894
- * Cameras emit these as separate RTP packets before the IDR/IRAP frame.
14895
- * We buffer them here and prepend to the next VCL NAL so downstream
14896
- * consumers (WebRTC, prebuffer, restream) receive complete access units
14897
- * with a single `EncodedPacket` that contains SPS+PPS+IDR together.
15167
+ * Groups the depacketizer's NALs into access units — ONE `EncodedPacket`
15168
+ * per picture, parameter sets welded onto the keyframe picture they
15169
+ * precede. See `access-unit-assembler.ts` for the boundary rule and the
15170
+ * per-NAL emit it replaced.
14898
15171
  */
14899
- pendingParamNals = [];
15172
+ annexBAssembler = new AccessUnitAssembler((accessUnit) => this.emitAccessUnit(accessUnit));
15173
+ /** Access-unit membership of the source RTP packets, for the restreamer's flags. */
15174
+ rtpAuTracker = new RtpAccessUnitTracker();
14900
15175
  /**
14901
15176
  * handleDepacketizedNal receives individual NALs from the RTP depacketizer
14902
- * and emits them as EncodedPackets.
14903
- *
14904
- * Parameter-set NALs (SPS/PPS type 7/8 for H.264, VPS/SPS/PPS type 32/33/34
14905
- * for H.265) are buffered and prepended to the next VCL NAL (slice/IDR).
14906
- * This guarantees every keyframe EncodedPacket is a self-contained access
14907
- * unit that a decoder can initialise from — critical for WebRTC (Chrome
14908
- * can't decode an IDR without preceding SPS/PPS).
15177
+ * and hands them to the access-unit assembler, which emits one
15178
+ * `EncodedPacket` per PICTURE (`emitAccessUnit`) — never per NAL. A
15179
+ * multi-slice IDR, its parameter sets and its prefix SEI are one keyframe
15180
+ * packet; three P slices are one P packet.
14909
15181
  *
14910
- * The RTSP restreamer handles its own RTP passthrough separately and is
14911
- * not affected by this merging.
15182
+ * The RTSP restreamer forwards source RTP separately (`onVideoRtp`) and is
15183
+ * not fed from here.
14912
15184
  */
14913
15185
  handleDepacketizedNal(depacketized) {
14914
- const { nal, keyframe, parameterSet, timestamp } = depacketized;
14915
- const codec = this.detectedCodec ?? "h264";
14916
- if (keyframe) this._lastNalKeyframe = true;
14917
- if (parameterSet) this._lastNalParamSet = true;
14918
- const isH264 = codec === "h264";
14919
- const nalType = isH264 ? nal[0] & 31 : (nal[0] & 126) >> 1;
14920
- if (isH264 ? nalType === 7 || nalType === 8 : nalType === 32 || nalType === 33 || nalType === 34) {
14921
- this.pendingParamNals.push(depacketized.annexB);
14922
- return;
15186
+ this.annexBAssembler.push(depacketized);
15187
+ }
15188
+ /**
15189
+ * Does anything consume the Annex-B plane right now? An encoded subscriber
15190
+ * (WebRTC Annex-B, fragments, a decode session), a pipe client, a clip ring
15191
+ * (it retains access units continuously, so it needs them built even with
15192
+ * nobody attached), or a restreamer still in Annex-B mode (no camera SDP yet).
15193
+ * The RTSP restream and the WebRTC RTP path are served from the source RTP
15194
+ * and never count. Cheap — four field reads — because it runs per packet.
15195
+ */
15196
+ needsAnnexBPlane() {
15197
+ return this.encodedCallbacks.size > 0 || this.clipBuffer !== null || this.pipeServer.getClientCount() > 0 || !this.rtspRestreamer.rtpPassthroughMode;
15198
+ }
15199
+ /** The consumers that want the plane, for the arm/idle lines. */
15200
+ annexBPlaneConsumers() {
15201
+ return {
15202
+ encodedSubscribers: this.encodedCallbacks.size,
15203
+ clipRing: this.clipBuffer !== null,
15204
+ pipeClients: this.pipeServer.getClientCount(),
15205
+ restreamerAnnexB: !this.rtspRestreamer.rtpPassthroughMode
15206
+ };
15207
+ }
15208
+ /**
15209
+ * Start building access units from the keyframe AU the current packet opens.
15210
+ * The depacketizer and the assembler are clean here (reset when the plane
15211
+ * went idle, or never used), so nothing stale precedes that keyframe.
15212
+ */
15213
+ armAnnexBPlane() {
15214
+ this.annexBPlaneActive = true;
15215
+ this.logger?.info("annex-b plane armed at a keyframe access unit", {
15216
+ tags: { deviceId: this.numericDeviceId },
15217
+ meta: this.annexBPlaneConsumers()
15218
+ });
15219
+ }
15220
+ /**
15221
+ * Stop building access units: the last consumer left. The picture in
15222
+ * progress is dropped, not flushed — there is nobody to hand it to, and a
15223
+ * partial picture must never reach the next consumer's clean start. Logged,
15224
+ * once per transition: a plane that is idle on purpose must not read as a
15225
+ * stream that stopped.
15226
+ */
15227
+ idleAnnexBPlane() {
15228
+ this.annexBPlaneActive = false;
15229
+ this.rtpDepacketizer?.reset();
15230
+ this.annexBAssembler.reset();
15231
+ this.logger?.info("annex-b plane idle — no consumer, NAL reassembly skipped", { tags: { deviceId: this.numericDeviceId } });
15232
+ }
15233
+ /**
15234
+ * One place for the input stats, fed from whichever path owns the pictures:
15235
+ * the source RTP packets while the native reader is up (`onVideoRtp`), the
15236
+ * `EncodedPacket`s otherwise (`pushEncodedPacket`). `bytes` accumulate per
15237
+ * call; `inputFps`, `packetCount` and the IDR interval advance once per
15238
+ * picture — the window is closed on a picture start so a window of N frames
15239
+ * reads N, exactly as it did when every call was one access unit.
15240
+ */
15241
+ recordVideoStats(bytes, pictureStart, keyframe) {
15242
+ this.totalBytes += bytes;
15243
+ this.bytesInWindow += bytes;
15244
+ if (!pictureStart) return;
15245
+ this.packetCount++;
15246
+ this.packetsInWindow++;
15247
+ const now = Date.now();
15248
+ this.lastPacketAt = now;
15249
+ const windowMs = now - this.windowStartMs;
15250
+ if (windowMs >= 2e3) {
15251
+ this.bitrateKbps = Math.round(this.bytesInWindow * 8 / windowMs);
15252
+ this.encodedInputFps = this.packetsInWindow / windowMs * 1e3;
15253
+ this.bytesInWindow = 0;
15254
+ this.packetsInWindow = 0;
15255
+ this.windowStartMs = now;
14923
15256
  }
14924
- const hadParamSets = this.pendingParamNals.length > 0;
14925
- let annexBData;
14926
- if (hadParamSets) {
14927
- this.pendingParamNals.push(depacketized.annexB);
14928
- annexBData = Buffer.concat(this.pendingParamNals);
14929
- this.pendingParamNals = [];
14930
- } else annexBData = depacketized.annexB;
14931
- const pts = Math.floor(timestamp / 90);
15257
+ if (keyframe) {
15258
+ if (this.lastKeyframeMs > 0) this.idrIntervalMs = now - this.lastKeyframeMs;
15259
+ this.lastKeyframeMs = now;
15260
+ }
15261
+ }
15262
+ /**
15263
+ * One assembled access unit → one `EncodedPacket` for every Annex-B
15264
+ * consumer (prebuffer, clip ring, pipe, WebRTC). A keyframe packet is
15265
+ * self-contained (VPS/SPS/PPS + every IDR slice), which is what a decoder
15266
+ * initialises from and what a clip or a pre-roll window may start at.
15267
+ */
15268
+ emitAccessUnit(accessUnit) {
15269
+ const codec = this.detectedCodec ?? "h264";
15270
+ const pts = Math.floor(accessUnit.timestamp / 90);
14932
15271
  const packet = {
14933
15272
  type: "video",
14934
- data: annexBData,
15273
+ data: accessUnit.annexB,
14935
15274
  pts,
14936
15275
  dts: pts,
14937
- keyframe: keyframe || hadParamSets,
15276
+ keyframe: accessUnit.keyframe,
14938
15277
  codec
14939
15278
  };
14940
15279
  this.pushEncodedPacket(packet);
@@ -15338,10 +15677,10 @@ var StreamBroker = class StreamBroker {
15338
15677
  this.rtpDepacketizer.reset();
15339
15678
  this.rtpDepacketizer = null;
15340
15679
  }
15341
- if (this.pushRtpFlagDepacketizer) {
15342
- this.pushRtpFlagDepacketizer.reset();
15343
- this.pushRtpFlagDepacketizer = null;
15344
- }
15680
+ this.annexBAssembler.flush();
15681
+ this.annexBAssembler.reset();
15682
+ this.rtpAuTracker.reset();
15683
+ this.annexBPlaneActive = false;
15345
15684
  this.resetRtpPreBuffer();
15346
15685
  if (this.audioRtpDecoder) {
15347
15686
  this.audioRtpDecoder.flush();