@camstack/addon-pipeline 1.2.364 → 1.2.366

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 +641 -206
  31. package/dist/stream-broker/index.mjs +641 -206
  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
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-CGOb--t-.js");
7
- const require_node = require("../node-UBMRK8SK.js");
6
+ const require_dist = require("../dist-i4kS_3O7.js");
7
+ const require_node = require("../node-TzgGsCvW.js");
8
8
  const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
9
9
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
- const require_segment_demux_js = require("../segment-demux-js-PkDP-xQg.js");
11
+ const require_segment_demux_js = require("../segment-demux-js-C9AYdKkD.js");
12
12
  let node_os = require("node:os");
13
13
  node_os = require_chunk.__toESM(node_os);
14
14
  let node_crypto = require("node:crypto");
@@ -3085,6 +3085,262 @@ var RtpPacketizer = class {
3085
3085
  }
3086
3086
  };
3087
3087
  //#endregion
3088
+ //#region src/stream-broker/stream-broker/broker-mem-probe.ts
3089
+ /** Cadence of the `[broker-mem]` line — aligned with the runner's `[mem]`. */
3090
+ var BROKER_MEM_INTERVAL_MS = 5 * 6e4;
3091
+ var copiedPackets = 0;
3092
+ var copiedBytes = 0;
3093
+ /** O(1) per packet: called by `copyOwned`. No allocation. */
3094
+ function recordCopyOwned(bytes) {
3095
+ copiedPackets += 1;
3096
+ copiedBytes += bytes;
3097
+ }
3098
+ function readCopyOwnedTotals() {
3099
+ return {
3100
+ packets: copiedPackets,
3101
+ bytes: copiedBytes
3102
+ };
3103
+ }
3104
+ /** `RssAnon:` / `RssFile:` out of `/proc/self/status` text; null when absent. */
3105
+ function parseProcStatus(text) {
3106
+ const anon = /^RssAnon:\s+(\d+)\s*kB/m.exec(text);
3107
+ const file = /^RssFile:\s+(\d+)\s*kB/m.exec(text);
3108
+ if (anon === null || file === null) return null;
3109
+ return {
3110
+ rssAnonKb: Number(anon[1]),
3111
+ rssFileKb: Number(file[1])
3112
+ };
3113
+ }
3114
+ /**
3115
+ * Summarise `/proc/self/maps`. Cheaper than `smaps_rollup`'s kernel page walk:
3116
+ * the file is one line per mapping and needs no per-page accounting. The
3117
+ * `[heap]` figure is the brk extent (virtual), which is the number glibc's
3118
+ * main arena grows when fragmented.
3119
+ *
3120
+ * Line shape: `start-end perms offset dev inode [path]` — an anonymous mapping
3121
+ * has no sixth field.
3122
+ */
3123
+ function parseProcMaps(text) {
3124
+ let heapBytes = 0;
3125
+ let anonMappings = 0;
3126
+ let anonBytes = 0;
3127
+ let from = 0;
3128
+ while (from < text.length) {
3129
+ let to = text.indexOf("\n", from);
3130
+ if (to === -1) to = text.length;
3131
+ const line = text.slice(from, to);
3132
+ from = to + 1;
3133
+ const dash = line.indexOf("-");
3134
+ const space = line.indexOf(" ");
3135
+ if (dash <= 0 || space <= dash) continue;
3136
+ const size = Number.parseInt(line.slice(dash + 1, space), 16) - Number.parseInt(line.slice(0, dash), 16);
3137
+ if (!Number.isFinite(size)) continue;
3138
+ const path = line.split(/\s+/, 6)[5];
3139
+ if (path === void 0 || path === "") {
3140
+ anonMappings += 1;
3141
+ anonBytes += size;
3142
+ } else if (path === "[heap]") heapBytes += size;
3143
+ }
3144
+ return {
3145
+ heapBytes,
3146
+ anonMappings,
3147
+ anonBytes
3148
+ };
3149
+ }
3150
+ /** Opened/closed consumers since the last reading; `drain()` resets. */
3151
+ var ConsumerChurn = class {
3152
+ opened = 0;
3153
+ closed = 0;
3154
+ openedByKind = /* @__PURE__ */ new Map();
3155
+ closedByKind = /* @__PURE__ */ new Map();
3156
+ recordOpen(kind) {
3157
+ this.opened += 1;
3158
+ this.openedByKind.set(kind, (this.openedByKind.get(kind) ?? 0) + 1);
3159
+ }
3160
+ recordClose(kind) {
3161
+ this.closed += 1;
3162
+ this.closedByKind.set(kind, (this.closedByKind.get(kind) ?? 0) + 1);
3163
+ }
3164
+ drain() {
3165
+ const reading = {
3166
+ opened: this.opened,
3167
+ closed: this.closed,
3168
+ openedByKind: Object.fromEntries(this.openedByKind),
3169
+ closedByKind: Object.fromEntries(this.closedByKind)
3170
+ };
3171
+ this.opened = 0;
3172
+ this.closed = 0;
3173
+ this.openedByKind = /* @__PURE__ */ new Map();
3174
+ this.closedByKind = /* @__PURE__ */ new Map();
3175
+ return reading;
3176
+ }
3177
+ };
3178
+ /** The one churn counter of this process (the broker runner). */
3179
+ var consumerChurn = new ConsumerChurn();
3180
+ /**
3181
+ * Sliding-window limiter for the attach/detach lines. `admit(now)` answers
3182
+ * whether this line may be written; the number of lines swallowed since the
3183
+ * last admitted one is returned with it so the next line can say so (a branch
3184
+ * that drops work names it).
3185
+ */
3186
+ function createLineLimiter(maxPerWindow, windowMs) {
3187
+ let windowStart = 0;
3188
+ let inWindow = 0;
3189
+ let suppressed = 0;
3190
+ return (now) => {
3191
+ if (now - windowStart >= windowMs) {
3192
+ windowStart = now;
3193
+ inWindow = 0;
3194
+ }
3195
+ if (inWindow >= maxPerWindow) {
3196
+ suppressed += 1;
3197
+ return {
3198
+ admit: false,
3199
+ suppressedBefore: 0
3200
+ };
3201
+ }
3202
+ inWindow += 1;
3203
+ const suppressedBefore = suppressed;
3204
+ suppressed = 0;
3205
+ return {
3206
+ admit: true,
3207
+ suppressedBefore
3208
+ };
3209
+ };
3210
+ }
3211
+ var consumerLineLimiter = createLineLimiter(40, 6e4);
3212
+ /**
3213
+ * One consumer attached to / detached from a broker: counted always (the
3214
+ * `[broker-mem]` line reports opened/closed since the last line), written at
3215
+ * info under a rate limit so a step in RSS can be matched to an event. The
3216
+ * line after a suppressed burst says how many were swallowed.
3217
+ */
3218
+ function reportConsumerEvent(logger, event, kind, deviceId, total, now = Date.now()) {
3219
+ if (event === "attach") consumerChurn.recordOpen(kind);
3220
+ else consumerChurn.recordClose(kind);
3221
+ if (logger === void 0 || logger === null) return;
3222
+ const verdict = consumerLineLimiter(now);
3223
+ if (!verdict.admit) return;
3224
+ const suppressed = verdict.suppressedBefore > 0 ? ` suppressedBefore=${verdict.suppressedBefore}` : "";
3225
+ logger.info(`consumer ${event} kind=${kind} total=${total}${suppressed}`, { ...deviceId !== void 0 ? { tags: { deviceId } } : {} });
3226
+ }
3227
+ var MB = 1024 * 1024;
3228
+ function ringBytesOf(s) {
3229
+ return s.preBufferBytes + s.clipBytes + s.rtpRingBytes + s.keyframeCacheBytes;
3230
+ }
3231
+ function mb(bytes) {
3232
+ return (bytes / MB).toFixed(1);
3233
+ }
3234
+ function kindList(counts) {
3235
+ const keys = Object.keys(counts).sort();
3236
+ return keys.length === 0 ? "none" : keys.map((k) => `${k}:${counts[k]}`).join(",");
3237
+ }
3238
+ /** One greppable `key=value` line; no per-device breakdown beyond the top N. */
3239
+ function formatBrokerMem(input) {
3240
+ const { samples, proc, churn, copied } = input;
3241
+ const consumers = {};
3242
+ let rtsp = 0;
3243
+ let audio = 0;
3244
+ let pipe = 0;
3245
+ let ingesting = 0;
3246
+ let planes = 0;
3247
+ let pre = 0;
3248
+ let clip = 0;
3249
+ let rtp = 0;
3250
+ let kf = 0;
3251
+ let qSum = 0;
3252
+ let qMax = 0;
3253
+ for (const s of samples) {
3254
+ if (s.ingesting) ingesting += 1;
3255
+ if (s.annexBPlaneActive) planes += 1;
3256
+ rtsp += s.rtspSessions;
3257
+ audio += s.audioSubscribers;
3258
+ pipe += s.pipeClients;
3259
+ pre += s.preBufferBytes;
3260
+ clip += s.clipBytes;
3261
+ rtp += s.rtpRingBytes;
3262
+ kf += s.keyframeCacheBytes;
3263
+ qSum += s.writeQueueSumBytes;
3264
+ if (s.writeQueueMaxBytes > qMax) qMax = s.writeQueueMaxBytes;
3265
+ for (const [kind, n] of Object.entries(s.encodedByKind)) consumers[kind] = (consumers[kind] ?? 0) + n;
3266
+ }
3267
+ const top = [...samples].filter((s) => s.deviceId !== void 0).sort((a, b) => ringBytesOf(b) - ringBytesOf(a)).slice(0, 5).map((s) => `${s.deviceId}:${mb(ringBytesOf(s))}`).join(",");
3268
+ const parts = ["[broker-mem]"];
3269
+ if (proc.status !== null) parts.push(`rssAnonMb=${(proc.status.rssAnonKb / 1024).toFixed(0)}`, `rssFileMb=${(proc.status.rssFileKb / 1024).toFixed(0)}`);
3270
+ if (proc.maps !== null) parts.push(`heapMapMb=${mb(proc.maps.heapBytes)}`, `anonMaps=${proc.maps.anonMappings}`, `anonMapsMb=${mb(proc.maps.anonBytes)}`);
3271
+ parts.push(`brokers=${samples.length}`, `ingesting=${ingesting}`, `annexBPlanes=${planes}`, `rtsp=${rtsp}`, `webrtc=${input.webrtcSessions}`, `encoded=${kindList(consumers)}`, `audioSubs=${audio}`, `pipeClients=${pipe}`, `egress=${input.egressTranscodes}`, `children=${input.childProcesses}`, `opened=${churn.opened}`, `closed=${churn.closed}`, `openedKinds=${kindList(churn.openedByKind)}`, `closedKinds=${kindList(churn.closedByKind)}`, `preBufferMb=${mb(pre)}`, `clipMb=${mb(clip)}`, `rtpRingMb=${mb(rtp)}`, `keyframeMb=${mb(kf)}`, `writeQueueSumMb=${mb(qSum)}`, `writeQueueMaxMb=${mb(qMax)}`, `copiedPackets=${copied.packets}`, `copiedMb=${mb(copied.bytes)}`, `topRingMb=${top === "" ? "none" : top}`);
3272
+ return parts.join(" ");
3273
+ }
3274
+ /** Count entries named `name` in `process.getActiveResourcesInfo()`. */
3275
+ function countActiveResources(resources, name) {
3276
+ let n = 0;
3277
+ for (const r of resources) if (r === name) n += 1;
3278
+ return n;
3279
+ }
3280
+ //#endregion
3281
+ //#region src/stream-broker/stream-broker/owned-buffer.ts
3282
+ /**
3283
+ * Buffers that enter a LONG-LIVED ring own their backing store.
3284
+ *
3285
+ * Node serves `Buffer.from(...)`, `Buffer.allocUnsafe(n)` and `Buffer.concat`
3286
+ * below `Buffer.poolSize >>> 1` (4 KiB) as views of shared 8 KiB pool slabs.
3287
+ * That is the right choice for the ingest path — a start code, a 4-byte
3288
+ * interleave header, a 1.3 KB RTP packet that is fanned out and forgotten
3289
+ * within a tick — and the wrong one for anything RETAINED: a 600-byte NAL kept
3290
+ * for 60 s in a clip ring pins its whole slab, and with it every short-lived
3291
+ * packet that happened to be allocated beside it. On the hub that held
3292
+ * 550–980 MB of ArrayBuffers for ~150–250 MB of payload, and the full GCs the
3293
+ * pinned slabs caused ran every 0.2–0.5 s at 40–65 % of a core.
3294
+ *
3295
+ * The ring byte caps counted payload bytes, so they never saw it. Both rings
3296
+ * now pass every entry through {@link detachFromPool} on the way in and
3297
+ * account it with {@link backingStoreBytes}, so the cap measures what the
3298
+ * entry costs. Short-lived traffic stays pooled.
3299
+ */
3300
+ /** True when `buf` is the whole of its ArrayBuffer — nothing else shares it. */
3301
+ function ownsBackingStore(buf) {
3302
+ return buf.byteOffset === 0 && buf.buffer.byteLength === buf.length;
3303
+ }
3304
+ /**
3305
+ * What retaining `buf` really costs: its own length when it owns its
3306
+ * ArrayBuffer, the whole shared slab when it is a view of one.
3307
+ */
3308
+ function backingStoreBytes(buf) {
3309
+ return ownsBackingStore(buf) ? buf.length : buf.buffer.byteLength;
3310
+ }
3311
+ /**
3312
+ * `buf` itself when it already owns its ArrayBuffer (decided by inspecting the
3313
+ * buffer, never by its size: whether a large `allocUnsafe` is pooled is a Node
3314
+ * runtime detail), else a copy that does. One copy, never two: a buffer that is
3315
+ * already unpooled is returned as-is.
3316
+ */
3317
+ function detachFromPool(buf) {
3318
+ if (ownsBackingStore(buf)) return buf;
3319
+ const owned = Buffer.allocUnsafeSlow(buf.length);
3320
+ buf.copy(owned);
3321
+ return owned;
3322
+ }
3323
+ /**
3324
+ * A copy of `src[start, end)` that OWNS its backing store — the shape a reader
3325
+ * hands out when the packet it cuts from its socket buffer is going to be
3326
+ * retained anyway.
3327
+ *
3328
+ * Every source video packet is: `captureRtpForPreBuffer` keeps it for the life
3329
+ * of a GOP, and the restreamer's keyframe cache keeps the keyframe ones. With
3330
+ * the readers cutting packets as `Buffer.from(subarray)` — a pooled view — the
3331
+ * ring then had to copy each one a second time to own it: two copies of every
3332
+ * packet on the ingest path (measured 15.1 + 14.8 MB/s on the hub's load).
3333
+ * Cutting the packet owned up front makes the ring's and the cache's
3334
+ * `detachFromPool` an identity, so the packet is copied once and shared by the
3335
+ * reader's fan-out, the ring, the cache and every session's write queue.
3336
+ */
3337
+ function copyOwned(src, start, end) {
3338
+ const owned = Buffer.allocUnsafeSlow(end - start);
3339
+ src.copy(owned, 0, start, end);
3340
+ recordCopyOwned(end - start);
3341
+ return owned;
3342
+ }
3343
+ //#endregion
3088
3344
  //#region src/stream-broker/rtsp/sdp-builder.ts
3089
3345
  function buildSdp(options) {
3090
3346
  const { codec, parameterSets, streamName } = options;
@@ -3694,6 +3950,26 @@ var RtspRestreamer = class {
3694
3950
  for (const session of this.sessions.values()) if (session.isPrestreamIntent()) n += 1;
3695
3951
  return n;
3696
3952
  }
3953
+ /**
3954
+ * What this restreamer holds, for the `[broker-mem]` probe: live sessions,
3955
+ * their socket write queues (sum and max) and the keyframe caches. One pass
3956
+ * over the sessions, no allocation beyond the result.
3957
+ */
3958
+ getMemProbe() {
3959
+ let sum = 0;
3960
+ let max = 0;
3961
+ for (const s of this.sessions.values()) {
3962
+ const q = s.getWriteQueueBytes();
3963
+ sum += q;
3964
+ if (q > max) max = q;
3965
+ }
3966
+ return {
3967
+ sessions: this.sessions.size,
3968
+ writeQueueSumBytes: sum,
3969
+ writeQueueMaxBytes: max,
3970
+ keyframeCacheBytes: this.lastKeyframeNalBytes + this.lastKeyframeRtpBytes
3971
+ };
3972
+ }
3697
3973
  /** Per-session diagnostic snapshots consumed by `StreamBroker.listClients`. */
3698
3974
  listSessionInfos() {
3699
3975
  return [...this.sessions.values()].map((s) => s.getInfo());
@@ -3705,6 +3981,7 @@ var RtspRestreamer = class {
3705
3981
  streamName: this.streamName,
3706
3982
  total: this.sessions.size
3707
3983
  } });
3984
+ reportConsumerEvent(this.logger, "attach", "rtsp", this.deviceId, this.sessions.size);
3708
3985
  this._onSessionCountChanged?.();
3709
3986
  this.reconcileSentryAudio();
3710
3987
  if (this._rtpPassthroughMode) setTimeout(() => this.servePendingSessionsRtp(), 0);
@@ -3755,7 +4032,7 @@ var RtspRestreamer = class {
3755
4032
  if (this.collectingKeyframeRtp && !isKeyframe && !isParameterSet) this.collectingKeyframeRtp = false;
3756
4033
  if (this.collectingKeyframeRtp) {
3757
4034
  this.lastKeyframeRtpPackets.push({
3758
- data: Buffer.from(rtpData),
4035
+ data: detachFromPool(rtpData),
3759
4036
  marker: rtpPacket.marker
3760
4037
  });
3761
4038
  this.lastKeyframeRtpBytes += rtpData.length;
@@ -4169,6 +4446,7 @@ var RtspRestreamer = class {
4169
4446
  streamName: this.streamName,
4170
4447
  total: this.sessions.size
4171
4448
  } });
4449
+ reportConsumerEvent(this.logger, "detach", "rtsp", this.deviceId, this.sessions.size);
4172
4450
  this._onSessionCountChanged?.();
4173
4451
  this.reconcileSentryAudio();
4174
4452
  }
@@ -4357,6 +4635,19 @@ var RtspSession = class {
4357
4635
  lastRtpAt = 0;
4358
4636
  /** Total video RTP bytes written to the socket. */
4359
4637
  bytesSent = 0;
4638
+ /**
4639
+ * The socket is corked for the access unit in progress — see
4640
+ * {@link writeInterleaved}. Cleared by the packet that ends the picture (RTP
4641
+ * marker, or any audio packet) or by the next-tick safety net, whichever
4642
+ * comes first.
4643
+ */
4644
+ accessUnitCorked = false;
4645
+ /** Flush the access unit in progress. Idempotent: the marker and the safety net both call it. */
4646
+ uncorkAccessUnit = () => {
4647
+ if (!this.accessUnitCorked) return;
4648
+ this.accessUnitCorked = false;
4649
+ this.socket.uncork();
4650
+ };
4360
4651
  /** Client `User-Agent` (from the first request that carries one). Lets the
4361
4652
  * broker panel identify a consumer by purpose — e.g. the recorder sets
4362
4653
  * `-user_agent recorder` on its RTSP pull — instead of a bare address. */
@@ -4425,6 +4716,10 @@ var RtspSession = class {
4425
4716
  this.onClose?.();
4426
4717
  });
4427
4718
  }
4719
+ /** Bytes the socket has accepted but not yet flushed (corked bytes included). */
4720
+ getWriteQueueBytes() {
4721
+ return this.socket.writableLength;
4722
+ }
4428
4723
  getSessionId() {
4429
4724
  return this.sessionId;
4430
4725
  }
@@ -4604,7 +4899,7 @@ var RtspSession = class {
4604
4899
  return "dropped-backlog";
4605
4900
  }
4606
4901
  }
4607
- this.writeInterleaved(this.tracks[0].rtpChannel, packet.data);
4902
+ this.writeInterleaved(this.tracks[0].rtpChannel, packet.data, packet.marker);
4608
4903
  this.missedKeyframes = 0;
4609
4904
  return "written";
4610
4905
  }
@@ -4613,7 +4908,7 @@ var RtspSession = class {
4613
4908
  if (!this.playing || this.closed || this.muted) return "not-playing";
4614
4909
  if (this.tracks.length < 2) return "not-playing";
4615
4910
  if (this.socket.writableLength > 2097152) return "dropped-backlog";
4616
- this.writeInterleaved(this.tracks[1].rtpChannel, packet.data);
4911
+ this.writeInterleaved(this.tracks[1].rtpChannel, packet.data, true);
4617
4912
  return "written";
4618
4913
  }
4619
4914
  /**
@@ -4635,14 +4930,13 @@ var RtspSession = class {
4635
4930
  * ## Why sharing the payload is safe, and what would make it unsafe
4636
4931
  *
4637
4932
  * The payload buffer handed here is a private allocation per packet —
4638
- * `Buffer.from(readBuffer.subarray(...))` in `rtsp-client.ts`'s `onData`, for
4639
- * video and audio alike — never a view of the reader's socket buffer, which
4640
- * advances on the next read. Nothing in the broker writes into a captured
4641
- * packet: every mutation in this path targets a freshly allocated buffer.
4642
- * (The broker's `rtpRing` keeps its OWN copy of a pooled packet —
4643
- * `detachFromPool`, so a ring entry never pins a shared pool slab — so the
4644
- * ring no longer vouches for THIS object's lifetime, and it does not need
4645
- * to: a pending write holds its own reference, and the rule below is about
4933
+ * `copyOwned(readBuffer, …)` in the readers' `onData`, for video and audio
4934
+ * alike — never a view of the reader's socket buffer, which advances on the
4935
+ * next read. Nothing in the broker writes into a captured packet: every
4936
+ * mutation in this path targets a freshly allocated buffer. (The same object
4937
+ * is what the broker's `rtpRing` and the restreamer's keyframe cache retain —
4938
+ * it owns its backing store, so `detachFromPool` keeps it as is — and that is
4939
+ * fine: a pending write holds its own reference, and the rule below is about
4646
4940
  * mutation, not retention.)
4647
4941
  *
4648
4942
  * The rule to keep: **`socket.write` forbids MUTATION before the write
@@ -4652,18 +4946,36 @@ var RtspSession = class {
4652
4946
  * queued. Four bytes from the Buffer pool is a pointer bump; the copy this
4653
4947
  * replaces was ~1.4 KB.
4654
4948
  *
4655
- * `cork`/`uncork` keeps the pair a single `writev` syscall, so removing the
4656
- * copy does not buy an extra write syscall per packet.
4949
+ * ## One cork per ACCESS UNIT, not per packet
4950
+ *
4951
+ * The pair used to be corked and uncorked on its own: one `writev` per RTP
4952
+ * packet per session, so a 4K keyframe fragmented into a hundred packets and
4953
+ * fanned out to three sessions was three hundred syscalls for one picture no
4954
+ * decoder can use before it is whole. The socket is corked on the first write
4955
+ * of an access unit and uncorked by the packet that ENDS it
4956
+ * (`endOfAccessUnit`: the RTP marker on video, every audio packet), so the
4957
+ * picture leaves in one `writev`. A `process.nextTick` safety net uncorks
4958
+ * whatever is still open before the tick yields to I/O — a marker-less camera
4959
+ * or a lost marker degrades to one `writev` per tick, never to a socket held
4960
+ * corked across a read.
4961
+ *
4962
+ * Backpressure sees through the cork: `writableLength` counts corked bytes,
4963
+ * so the `MAX_WRITE_BUFFER` gate, the latch and the missed-keyframe close
4964
+ * (D447) judge exactly what they judged per packet.
4657
4965
  */
4658
- writeInterleaved(channel, payload) {
4966
+ writeInterleaved(channel, payload, endOfAccessUnit) {
4659
4967
  const header = Buffer.allocUnsafe(4);
4660
4968
  header[0] = 36;
4661
4969
  header[1] = channel;
4662
4970
  header.writeUInt16BE(payload.length, 2);
4663
- this.socket.cork();
4971
+ if (!this.accessUnitCorked) {
4972
+ this.accessUnitCorked = true;
4973
+ this.socket.cork();
4974
+ process.nextTick(this.uncorkAccessUnit);
4975
+ }
4664
4976
  this.socket.write(header);
4665
4977
  this.socket.write(payload);
4666
- this.socket.uncork();
4978
+ if (endOfAccessUnit) this.uncorkAccessUnit();
4667
4979
  this.lastRtpAt = Date.now();
4668
4980
  this.bytesSent += 4 + payload.length;
4669
4981
  }
@@ -6361,6 +6673,10 @@ var EgressTranscodeManager = class {
6361
6673
  /** In-flight starts, keyed by sharing key — concurrent acquires share one. */
6362
6674
  pending = /* @__PURE__ */ new Map();
6363
6675
  releaseGraceMs;
6676
+ /** Live egress transcodes (ffmpeg children), for the `[broker-mem]` probe. */
6677
+ activeCount() {
6678
+ return this.entries.size;
6679
+ }
6364
6680
  constructor(opts) {
6365
6681
  this.opts = opts;
6366
6682
  this.releaseGraceMs = opts.releaseGraceMs ?? DEFAULT_RELEASE_GRACE_MS;
@@ -10087,7 +10403,7 @@ var Rfc4571Reader = class {
10087
10403
  while (this.readBuffer.length >= 2) {
10088
10404
  const length = this.readBuffer.readUInt16BE(0);
10089
10405
  if (this.readBuffer.length < 2 + length) return;
10090
- const rtpPacket = Buffer.from(this.readBuffer.subarray(2, 2 + length));
10406
+ const rtpPacket = copyOwned(this.readBuffer, 2, 2 + length);
10091
10407
  this.readBuffer = this.readBuffer.subarray(2 + length);
10092
10408
  this.dispatchRtp(rtpPacket);
10093
10409
  }
@@ -10148,6 +10464,93 @@ function totalFragmentBytes(fragments) {
10148
10464
  for (const fragment of fragments) total += fragment.length;
10149
10465
  return total;
10150
10466
  }
10467
+ /** H.264: single NAL by its type; STAP-A by every aggregated type; FU-A by the FU header. */
10468
+ function classifyH264Payload(payload) {
10469
+ const nalType = payload[0] & 31;
10470
+ if (nalType === H264_NAL_STAP_A) {
10471
+ let keyframe = false;
10472
+ let parameterSet = false;
10473
+ let offset = 1;
10474
+ while (offset + 2 <= payload.length) {
10475
+ const nalSize = payload.readUInt16BE(offset);
10476
+ offset += 2;
10477
+ if (nalSize === 0 || offset + nalSize > payload.length) break;
10478
+ const type = payload[offset] & 31;
10479
+ keyframe ||= type === H264_NAL_IDR;
10480
+ parameterSet ||= type === H264_NAL_SPS || type === H264_NAL_PPS;
10481
+ offset += nalSize;
10482
+ }
10483
+ return {
10484
+ keyframe,
10485
+ parameterSet
10486
+ };
10487
+ }
10488
+ if (nalType === 28) {
10489
+ if (payload.length < 2) return NO_PAYLOAD_CLASS;
10490
+ return {
10491
+ keyframe: (payload[1] & 31) === H264_NAL_IDR,
10492
+ parameterSet: false
10493
+ };
10494
+ }
10495
+ return {
10496
+ keyframe: nalType === H264_NAL_IDR,
10497
+ parameterSet: nalType === H264_NAL_SPS || nalType === H264_NAL_PPS
10498
+ };
10499
+ }
10500
+ /** H.265: single NAL by its type; AP by every aggregated type; FU by the FU header. */
10501
+ function classifyH265Payload(payload) {
10502
+ if (payload.length < 2) return NO_PAYLOAD_CLASS;
10503
+ const nalType = payload[0] >> 1 & 63;
10504
+ if (nalType === H265_NAL_AP) {
10505
+ let keyframe = false;
10506
+ let parameterSet = false;
10507
+ let offset = 2;
10508
+ while (offset + 2 <= payload.length) {
10509
+ const nalSize = payload.readUInt16BE(offset);
10510
+ offset += 2;
10511
+ if (nalSize === 0 || offset + nalSize > payload.length) break;
10512
+ const type = payload[offset] >> 1 & 63;
10513
+ keyframe ||= isH265IrapType(type);
10514
+ parameterSet ||= isH265ParameterSetType(type);
10515
+ offset += nalSize;
10516
+ }
10517
+ return {
10518
+ keyframe,
10519
+ parameterSet
10520
+ };
10521
+ }
10522
+ if (nalType === 49) {
10523
+ if (payload.length < 3) return NO_PAYLOAD_CLASS;
10524
+ return {
10525
+ keyframe: isH265IrapType(payload[2] & 63),
10526
+ parameterSet: false
10527
+ };
10528
+ }
10529
+ return {
10530
+ keyframe: isH265IrapType(nalType),
10531
+ parameterSet: isH265ParameterSetType(nalType)
10532
+ };
10533
+ }
10534
+ /**
10535
+ * Classify one raw RTP packet (12-byte header + payload) by what it carries —
10536
+ * the per-packet half of {@link RtpDepacketizer.processPacket}, without the
10537
+ * reassembly half. Pure: reads the headers, allocates nothing, keeps no state.
10538
+ * `null` for a packet too short to be RTP with a payload.
10539
+ */
10540
+ function classifyRtpPacket(codec, rtpData) {
10541
+ if (rtpData.length < 12) return null;
10542
+ const marker = (rtpData[1] & 128) !== 0;
10543
+ const timestamp = rtpData.readUInt32BE(4);
10544
+ const payload = rtpData.subarray(12);
10545
+ if (payload.length === 0) return null;
10546
+ const carried = codec === "h264" ? classifyH264Payload(payload) : classifyH265Payload(payload);
10547
+ return {
10548
+ timestamp,
10549
+ marker,
10550
+ keyframe: carried.keyframe,
10551
+ parameterSet: carried.parameterSet
10552
+ };
10553
+ }
10151
10554
  /**
10152
10555
  * RTP → Annex-B NAL depacketizer.
10153
10556
  *
@@ -10192,87 +10595,22 @@ var RtpDepacketizer = class {
10192
10595
  * short to be RTP with a payload.
10193
10596
  */
10194
10597
  processPacket(rtpData) {
10195
- if (rtpData.length < 12) return null;
10196
- const marker = (rtpData[1] & 128) !== 0;
10197
- const timestamp = rtpData.readUInt32BE(4);
10598
+ const carried = this.classifyPacket(rtpData);
10599
+ if (carried === null) return null;
10198
10600
  const payload = rtpData.subarray(12);
10199
- if (payload.length === 0) return null;
10200
- const carried = this.codec === "h264" ? this.classifyH264Payload(payload) : this.classifyH265Payload(payload);
10201
- if (this.codec === "h264") this.processH264(payload, timestamp, marker);
10202
- else this.processH265(payload, timestamp, marker);
10203
- return {
10204
- timestamp,
10205
- marker,
10206
- keyframe: carried.keyframe,
10207
- parameterSet: carried.parameterSet
10208
- };
10209
- }
10210
- /** H.264: single NAL by its type; STAP-A by every aggregated type; FU-A by the FU header. */
10211
- classifyH264Payload(payload) {
10212
- const nalType = payload[0] & 31;
10213
- if (nalType === H264_NAL_STAP_A) {
10214
- let keyframe = false;
10215
- let parameterSet = false;
10216
- let offset = 1;
10217
- while (offset + 2 <= payload.length) {
10218
- const nalSize = payload.readUInt16BE(offset);
10219
- offset += 2;
10220
- if (nalSize === 0 || offset + nalSize > payload.length) break;
10221
- const type = payload[offset] & 31;
10222
- keyframe ||= type === H264_NAL_IDR;
10223
- parameterSet ||= type === H264_NAL_SPS || type === H264_NAL_PPS;
10224
- offset += nalSize;
10225
- }
10226
- return {
10227
- keyframe,
10228
- parameterSet
10229
- };
10230
- }
10231
- if (nalType === 28) {
10232
- if (payload.length < 2) return NO_PAYLOAD_CLASS;
10233
- return {
10234
- keyframe: (payload[1] & 31) === H264_NAL_IDR,
10235
- parameterSet: false
10236
- };
10237
- }
10238
- return {
10239
- keyframe: nalType === H264_NAL_IDR,
10240
- parameterSet: nalType === H264_NAL_SPS || nalType === H264_NAL_PPS
10241
- };
10601
+ if (this.codec === "h264") this.processH264(payload, carried.timestamp, carried.marker);
10602
+ else this.processH265(payload, carried.timestamp, carried.marker);
10603
+ return carried;
10242
10604
  }
10243
- /** H.265: single NAL by its type; AP by every aggregated type; FU by the FU header. */
10244
- classifyH265Payload(payload) {
10245
- if (payload.length < 2) return NO_PAYLOAD_CLASS;
10246
- const nalType = payload[0] >> 1 & 63;
10247
- if (nalType === H265_NAL_AP) {
10248
- let keyframe = false;
10249
- let parameterSet = false;
10250
- let offset = 2;
10251
- while (offset + 2 <= payload.length) {
10252
- const nalSize = payload.readUInt16BE(offset);
10253
- offset += 2;
10254
- if (nalSize === 0 || offset + nalSize > payload.length) break;
10255
- const type = payload[offset] >> 1 & 63;
10256
- keyframe ||= isH265IrapType(type);
10257
- parameterSet ||= isH265ParameterSetType(type);
10258
- offset += nalSize;
10259
- }
10260
- return {
10261
- keyframe,
10262
- parameterSet
10263
- };
10264
- }
10265
- if (nalType === 49) {
10266
- if (payload.length < 3) return NO_PAYLOAD_CLASS;
10267
- return {
10268
- keyframe: isH265IrapType(payload[2] & 63),
10269
- parameterSet: false
10270
- };
10271
- }
10272
- return {
10273
- keyframe: isH265IrapType(nalType),
10274
- parameterSet: isH265ParameterSetType(nalType)
10275
- };
10605
+ /**
10606
+ * What this packet CARRIES, and nothing else: no NAL is reassembled, no buffer
10607
+ * allocated, no reassembly state touched. The same verdict `processPacket`
10608
+ * returns, for a broker whose Annex-B plane has no consumer — the source-RTP
10609
+ * ring, the restreamer's keyframe cache and the stats need exactly this and
10610
+ * none of the copies.
10611
+ */
10612
+ classifyPacket(rtpData) {
10613
+ return classifyRtpPacket(this.codec, rtpData);
10276
10614
  }
10277
10615
  processH264(payload, timestamp, marker) {
10278
10616
  const nalType = payload[0] & 31;
@@ -10452,11 +10790,13 @@ var RtpAccessUnitTracker = class {
10452
10790
  const keyframeContent = packet.keyframe || packet.parameterSet;
10453
10791
  const keyframeAuStart = keyframeContent && !this.keyframeAu;
10454
10792
  if (keyframeContent) this.keyframeAu = true;
10793
+ const pictureStart = !packet.parameterSet && !this.pictureSeen;
10455
10794
  if (!packet.parameterSet) this.pictureSeen = true;
10456
10795
  return {
10457
10796
  auStart,
10458
10797
  keyframeAu: this.keyframeAu,
10459
10798
  keyframeAuStart,
10799
+ pictureStart,
10460
10800
  parameterSet: packet.parameterSet
10461
10801
  };
10462
10802
  }
@@ -10782,7 +11122,7 @@ var NativeRtspClient = class {
10782
11122
  const channel = this.readBuffer[1];
10783
11123
  const length = this.readBuffer.readUInt16BE(2);
10784
11124
  if (this.readBuffer.length < 4 + length) return;
10785
- const payload = Buffer.from(this.readBuffer.subarray(4, 4 + length));
11125
+ const payload = copyOwned(this.readBuffer, 4, 4 + length);
10786
11126
  this.readBuffer = this.readBuffer.subarray(4 + length);
10787
11127
  this.handleInterleavedPacket(channel, payload);
10788
11128
  } else {
@@ -11518,49 +11858,6 @@ var AudioCodecSession = class {
11518
11858
  }
11519
11859
  };
11520
11860
  //#endregion
11521
- //#region src/stream-broker/stream-broker/owned-buffer.ts
11522
- /**
11523
- * Buffers that enter a LONG-LIVED ring own their backing store.
11524
- *
11525
- * Node serves `Buffer.from(...)`, `Buffer.allocUnsafe(n)` and `Buffer.concat`
11526
- * below `Buffer.poolSize >>> 1` (4 KiB) as views of shared 8 KiB pool slabs.
11527
- * That is the right choice for the ingest path — a start code, a 4-byte
11528
- * interleave header, a 1.3 KB RTP packet that is fanned out and forgotten
11529
- * within a tick — and the wrong one for anything RETAINED: a 600-byte NAL kept
11530
- * for 60 s in a clip ring pins its whole slab, and with it every short-lived
11531
- * packet that happened to be allocated beside it. On the hub that held
11532
- * 550–980 MB of ArrayBuffers for ~150–250 MB of payload, and the full GCs the
11533
- * pinned slabs caused ran every 0.2–0.5 s at 40–65 % of a core.
11534
- *
11535
- * The ring byte caps counted payload bytes, so they never saw it. Both rings
11536
- * now pass every entry through {@link detachFromPool} on the way in and
11537
- * account it with {@link backingStoreBytes}, so the cap measures what the
11538
- * entry costs. Short-lived traffic stays pooled.
11539
- */
11540
- /** True when `buf` is the whole of its ArrayBuffer — nothing else shares it. */
11541
- function ownsBackingStore(buf) {
11542
- return buf.byteOffset === 0 && buf.buffer.byteLength === buf.length;
11543
- }
11544
- /**
11545
- * What retaining `buf` really costs: its own length when it owns its
11546
- * ArrayBuffer, the whole shared slab when it is a view of one.
11547
- */
11548
- function backingStoreBytes(buf) {
11549
- return ownsBackingStore(buf) ? buf.length : buf.buffer.byteLength;
11550
- }
11551
- /**
11552
- * `buf` itself when it already owns its ArrayBuffer (decided by inspecting the
11553
- * buffer, never by its size: whether a large `allocUnsafe` is pooled is a Node
11554
- * runtime detail), else a copy that does. One copy, never two: a buffer that is
11555
- * already unpooled is returned as-is.
11556
- */
11557
- function detachFromPool(buf) {
11558
- if (ownsBackingStore(buf)) return buf;
11559
- const owned = Buffer.allocUnsafeSlow(buf.length);
11560
- buf.copy(owned);
11561
- return owned;
11562
- }
11563
- //#endregion
11564
11861
  //#region src/stream-broker/stream-broker/encoded-ring-buffer.ts
11565
11862
  /**
11566
11863
  * Time-based ring buffer for encoded video packets.
@@ -12500,9 +12797,6 @@ var StreamBroker = class StreamBroker {
12500
12797
  */
12501
12798
  ffmpegBinaryPathSource = () => null;
12502
12799
  rtpDepacketizer = null;
12503
- /** Flag-only depacketizer for the `push-rtp` source-RTP pre-buffer path
12504
- * (sets keyframe/param-set flags without re-emitting AnnexB packets). */
12505
- pushRtpFlagDepacketizer = null;
12506
12800
  audioRtpDecoder = null;
12507
12801
  audioCodecSession = null;
12508
12802
  /**
@@ -12746,6 +13040,8 @@ var StreamBroker = class StreamBroker {
12746
13040
  * timeout was chosen against.
12747
13041
  */
12748
13042
  static RTP_ACTIVITY_SWEEP_MS = 1e3;
13043
+ /** A source counts as ingesting when its last video packet is younger than this. */
13044
+ static INGESTING_WITHIN_MS = 1e4;
12749
13045
  manualStop = false;
12750
13046
  stopping = false;
12751
13047
  pipeServer;
@@ -12870,10 +13166,24 @@ var StreamBroker = class StreamBroker {
12870
13166
  * log alongside `audioRtpSeen`, so a wake-up cycle that brings audio
12871
13167
  * back but never video is observable from the structured logs. */
12872
13168
  videoRtpSeen = 0;
12873
- /** Tracking flags set synchronously by the RTP depacketizer callback. */
13169
+ /**
13170
+ * What the source RTP packet being handled CARRIES (keyframe / parameter-set
13171
+ * content, read from its payload headers by `classifyRtpPacket`) — the flags
13172
+ * `captureRtpForPreBuffer` reads. Per packet, not per completed NAL: every
13173
+ * fragment of a fragmented IDR carries them, and the ring marks one keyframe
13174
+ * AU for the lot.
13175
+ */
12874
13176
  _lastNalKeyframe = false;
12875
13177
  _lastNalParamSet = false;
12876
13178
  /**
13179
+ * Whether the Annex-B plane (NAL reassembly + access-unit assembly →
13180
+ * `EncodedPacket`) is being BUILT for this stream. False while nothing
13181
+ * consumes it: the source-RTP path then classifies each packet and copies
13182
+ * nothing. Armed at the next keyframe access unit once a consumer attaches
13183
+ * (`needsAnnexBPlane`), so the first packet it emits is decodable.
13184
+ */
13185
+ annexBPlaneActive = false;
13186
+ /**
12877
13187
  * Source-RTP pre-buffer: a ring of raw source RTP packets trimmed to
12878
13188
  * begin at the most recent keyframe access unit — i.e. the current GOP so
12879
13189
  * far. A late-joining WebRTC viewer on the repacketizer path replays this
@@ -13006,6 +13316,33 @@ var StreamBroker = class StreamBroker {
13006
13316
  return this.lastPacketAt;
13007
13317
  }
13008
13318
  /**
13319
+ * What this broker holds, for the `[broker-mem]` probe (`broker-mem-probe.ts`).
13320
+ * Ring sizes are the rings' own counters (backing-store bytes), so this is
13321
+ * O(sessions + subscribers) and allocates only the result.
13322
+ */
13323
+ getMemSample(now) {
13324
+ const encodedByKind = {};
13325
+ for (const entry of this.encodedCallbacks.values()) encodedByKind[entry.attribution.kind] = (encodedByKind[entry.attribution.kind] ?? 0) + 1;
13326
+ for (const entry of this.rtpVideoCallbacks.values()) encodedByKind[entry.attribution.kind] = (encodedByKind[entry.attribution.kind] ?? 0) + 1;
13327
+ const rtsp = this.rtspRestreamer.getMemProbe();
13328
+ return {
13329
+ deviceId: this.numericDeviceId,
13330
+ brokerId: this.deviceId,
13331
+ ingesting: this.lastPacketAt > 0 && now - this.lastPacketAt < StreamBroker.INGESTING_WITHIN_MS,
13332
+ preBufferBytes: this.preBuffer.getBufferedBytes(),
13333
+ clipBytes: this.clipBuffer?.getBufferedBytes() ?? 0,
13334
+ rtpRingBytes: this.rtpRingBytes,
13335
+ keyframeCacheBytes: rtsp.keyframeCacheBytes,
13336
+ rtspSessions: rtsp.sessions,
13337
+ writeQueueSumBytes: rtsp.writeQueueSumBytes,
13338
+ writeQueueMaxBytes: rtsp.writeQueueMaxBytes,
13339
+ encodedByKind,
13340
+ audioSubscribers: this.audioChunkPlane.subscriberCount,
13341
+ pipeClients: this.pipeServer.getClientCount(),
13342
+ annexBPlaneActive: this.annexBPlaneActive
13343
+ };
13344
+ }
13345
+ /**
13009
13346
  * Whether a LAZY publisher is being built right now.
13010
13347
  *
13011
13348
  * A consumer deciding whether it is worth waiting for this source needs to
@@ -13949,26 +14286,7 @@ var StreamBroker = class StreamBroker {
13949
14286
  return;
13950
14287
  }
13951
14288
  }
13952
- if (packet.type === "video") {
13953
- this.packetCount++;
13954
- this.totalBytes += packet.data.length;
13955
- this.bytesInWindow += packet.data.length;
13956
- this.packetsInWindow++;
13957
- this.lastPacketAt = Date.now();
13958
- const now = Date.now();
13959
- const windowMs = now - this.windowStartMs;
13960
- if (windowMs >= 2e3) {
13961
- this.bitrateKbps = Math.round(this.bytesInWindow * 8 / windowMs);
13962
- this.encodedInputFps = this.packetsInWindow / windowMs * 1e3;
13963
- this.bytesInWindow = 0;
13964
- this.packetsInWindow = 0;
13965
- this.windowStartMs = now;
13966
- }
13967
- if (packet.keyframe) {
13968
- if (this.lastKeyframeMs > 0) this.idrIntervalMs = now - this.lastKeyframeMs;
13969
- this.lastKeyframeMs = now;
13970
- }
13971
- }
14289
+ if (packet.type === "video" && this.rtpDepacketizer === null) this.recordVideoStats(packet.data.length, true, packet.keyframe);
13972
14290
  if (packet.type === "video" && !this.isRtpSource()) this.preBuffer.push(packet);
13973
14291
  if (packet.type === "video" && this.clipBuffer && packet.isPlaceholder !== true) this.clipBuffer.push(packet);
13974
14292
  this.fanOutEncoded(packet);
@@ -13987,6 +14305,7 @@ var StreamBroker = class StreamBroker {
13987
14305
  packetsDelivered: 0
13988
14306
  };
13989
14307
  this.encodedCallbacks.set(callback, entry);
14308
+ reportConsumerEvent(this.logger, "attach", attribution.kind, this.numericDeviceId, this.encodedCallbacks.size + this.rtpVideoCallbacks.size);
13990
14309
  if (this.streamingDebug) this.logger?.info("encoded subscriber added", { meta: {
13991
14310
  total: this.encodedCallbacks.size,
13992
14311
  subscriberId: entry.id,
@@ -13997,6 +14316,7 @@ var StreamBroker = class StreamBroker {
13997
14316
  this.checkDemand();
13998
14317
  return () => {
13999
14318
  this.encodedCallbacks.delete(callback);
14319
+ reportConsumerEvent(this.logger, "detach", attribution.kind, this.numericDeviceId, this.encodedCallbacks.size + this.rtpVideoCallbacks.size);
14000
14320
  if (this.streamingDebug) this.logger?.info("encoded subscriber removed", { meta: {
14001
14321
  total: this.encodedCallbacks.size,
14002
14322
  subscriberId: entry.id
@@ -14091,6 +14411,7 @@ var StreamBroker = class StreamBroker {
14091
14411
  packetsDelivered: 0
14092
14412
  };
14093
14413
  this.rtpVideoCallbacks.set(callback, entry);
14414
+ reportConsumerEvent(this.logger, "attach", attribution.kind, this.numericDeviceId, this.encodedCallbacks.size + this.rtpVideoCallbacks.size);
14094
14415
  if (this.streamingDebug || CH_RTP.on) this.logger?.info("rtp video subscriber added", { meta: {
14095
14416
  total: this.rtpVideoCallbacks.size,
14096
14417
  subscriberId: entry.id,
@@ -14100,6 +14421,7 @@ var StreamBroker = class StreamBroker {
14100
14421
  this.checkDemand();
14101
14422
  return () => {
14102
14423
  this.rtpVideoCallbacks.delete(callback);
14424
+ reportConsumerEvent(this.logger, "detach", attribution.kind, this.numericDeviceId, this.encodedCallbacks.size + this.rtpVideoCallbacks.size);
14103
14425
  if (this.streamingDebug || CH_RTP.on) this.logger?.info("rtp video subscriber removed", { meta: {
14104
14426
  total: this.rtpVideoCallbacks.size,
14105
14427
  subscriberId: entry.id
@@ -14159,8 +14481,8 @@ var StreamBroker = class StreamBroker {
14159
14481
  index: auStart,
14160
14482
  ts: auPacket.readUInt32BE(4)
14161
14483
  });
14484
+ this.trimRtpRingToPreRollWindow();
14162
14485
  }
14163
- this.trimRtpRingToPreRollWindow();
14164
14486
  this.rtpRingHasKeyframe = true;
14165
14487
  }
14166
14488
  this.rtpRingPrevMarker = marker;
@@ -14429,10 +14751,10 @@ var StreamBroker = class StreamBroker {
14429
14751
  *
14430
14752
  * `push-rtp` providers deliver AnnexB-equivalents separately via
14431
14753
  * `pushEncodedPacket()` (see `IStreamBroker` contract) — so this path
14432
- * does NOT re-emit EncodedPackets; it only derives the per-packet
14433
- * keyframe/param-set flags the pre-buffer capture needs (via a
14434
- * flag-only depacketizer that never touches the AnnexB plane), keeping
14435
- * the AnnexB consumers (pipe, restreamer) single-fed by the provider.
14754
+ * does NOT re-emit EncodedPackets; it only classifies each packet for the
14755
+ * keyframe/param-set flags the pre-buffer capture needs
14756
+ * (`classifyRtpPacket`, no reassembly), keeping the AnnexB consumers
14757
+ * (pipe, restreamer) single-fed by the provider.
14436
14758
  *
14437
14759
  * The pre-buffer is captured even with zero subscribers attached — a
14438
14760
  * viewer that connects mid-GOP must be able to replay the GOP that
@@ -14442,9 +14764,9 @@ var StreamBroker = class StreamBroker {
14442
14764
  pushVideoRtp(rtpData) {
14443
14765
  if (this.stopping) return;
14444
14766
  this.kickRtpActivityWatchdog();
14445
- this._lastNalKeyframe = false;
14446
- this._lastNalParamSet = false;
14447
- this.ensurePushRtpFlagDepacketizer().processPacket(rtpData);
14767
+ const carried = classifyRtpPacket(this.detectedCodec === "h265" ? "h265" : "h264", rtpData);
14768
+ this._lastNalKeyframe = carried?.keyframe ?? false;
14769
+ this._lastNalParamSet = carried?.parameterSet ?? false;
14448
14770
  this.captureRtpForPreBuffer(rtpData);
14449
14771
  if (this.rtpVideoCallbacks.size === 0) return;
14450
14772
  for (const [cb, entry] of this.rtpVideoCallbacks) try {
@@ -14454,27 +14776,6 @@ var StreamBroker = class StreamBroker {
14454
14776
  this.logger?.warn("rtp video subscriber threw", { meta: { error: require_dist.errMsg(err) } });
14455
14777
  }
14456
14778
  }
14457
- /**
14458
- * Lazily build a flag-only RTP depacketizer for the `push-rtp` source
14459
- * path. Unlike the native reader's depacketizer (which emits AnnexB
14460
- * EncodedPackets via `handleDepacketizedNal`), this callback ONLY sets
14461
- * `_lastNalKeyframe`/`_lastNalParamSet` — the push provider owns the
14462
- * AnnexB plane through `pushEncodedPacket`, so re-emitting here would
14463
- * double-feed the pipe/restreamer. push-rtp has no SDP `onVideoTrack`,
14464
- * so the codec comes from the source declaration (`detectedCodec`, seeded
14465
- * in `start()`). Dropped on teardown (`resetRtpPipeline`) so a redial
14466
- * starts with clean FU reassembly state.
14467
- */
14468
- ensurePushRtpFlagDepacketizer() {
14469
- if (!this.pushRtpFlagDepacketizer) {
14470
- const codec = this.detectedCodec === "h265" ? "h265" : "h264";
14471
- this.pushRtpFlagDepacketizer = new RtpDepacketizer(codec, ({ keyframe, parameterSet }) => {
14472
- if (keyframe) this._lastNalKeyframe = true;
14473
- if (parameterSet) this._lastNalParamSet = true;
14474
- });
14475
- }
14476
- return this.pushRtpFlagDepacketizer;
14477
- }
14478
14779
  /** Toggle the per-device streaming debug flag. */
14479
14780
  setStreamingDebug(enabled) {
14480
14781
  this.streamingDebug = enabled;
@@ -14521,6 +14822,7 @@ var StreamBroker = class StreamBroker {
14521
14822
  tag,
14522
14823
  accept: options?.accept
14523
14824
  });
14825
+ reportConsumerEvent(this.logger, "attach", "audio-chunks", this.numericDeviceId, this.audioChunkPlane.subscriberCount);
14524
14826
  this.checkDemand();
14525
14827
  return result;
14526
14828
  }
@@ -14531,7 +14833,10 @@ var StreamBroker = class StreamBroker {
14531
14833
  /** Release an audio-chunk subscription. Returns `true` when it existed. */
14532
14834
  unsubscribeAudioChunks(subscriptionId) {
14533
14835
  const released = this.audioChunkPlane.unsubscribe(subscriptionId);
14534
- if (released) this.checkDemand();
14836
+ if (released) {
14837
+ reportConsumerEvent(this.logger, "detach", "audio-chunks", this.numericDeviceId, this.audioChunkPlane.subscriberCount);
14838
+ this.checkDemand();
14839
+ }
14535
14840
  return released;
14536
14841
  }
14537
14842
  getStats() {
@@ -14917,10 +15222,17 @@ var StreamBroker = class StreamBroker {
14917
15222
  seq: this.videoRtpSeen,
14918
15223
  bytes: rtpData.length
14919
15224
  } });
14920
- this._lastNalKeyframe = false;
14921
- this._lastNalParamSet = false;
14922
- const carried = this.rtpDepacketizer?.processPacket(rtpData) ?? null;
15225
+ const wantPlane = this.needsAnnexBPlane();
15226
+ if (!wantPlane && this.annexBPlaneActive) this.idleAnnexBPlane();
15227
+ const carried = this.annexBPlaneActive ? this.rtpDepacketizer?.processPacket(rtpData) ?? null : this.rtpDepacketizer?.classifyPacket(rtpData) ?? null;
15228
+ this._lastNalKeyframe = carried?.keyframe ?? false;
15229
+ this._lastNalParamSet = carried?.parameterSet ?? false;
14923
15230
  const auFlags = carried ? this.rtpAuTracker.classify(carried) : null;
15231
+ if (wantPlane && !this.annexBPlaneActive && auFlags?.keyframeAuStart === true) {
15232
+ this.armAnnexBPlane();
15233
+ this.rtpDepacketizer?.processPacket(rtpData);
15234
+ }
15235
+ if (auFlags) this.recordVideoStats(rtpData.length, auFlags.pictureStart, auFlags.keyframeAu);
14924
15236
  this.rtspRestreamer.pushRtpPassthrough(rtpData, auFlags?.keyframeAu ?? false, auFlags?.parameterSet ?? false);
14925
15237
  this.captureRtpForPreBuffer(rtpData);
14926
15238
  if (this.rtpVideoCallbacks.size > 0) for (const [cb, entry] of this.rtpVideoCallbacks) try {
@@ -15136,11 +15448,83 @@ var StreamBroker = class StreamBroker {
15136
15448
  * not fed from here.
15137
15449
  */
15138
15450
  handleDepacketizedNal(depacketized) {
15139
- if (depacketized.keyframe) this._lastNalKeyframe = true;
15140
- if (depacketized.parameterSet) this._lastNalParamSet = true;
15141
15451
  this.annexBAssembler.push(depacketized);
15142
15452
  }
15143
15453
  /**
15454
+ * Does anything consume the Annex-B plane right now? An encoded subscriber
15455
+ * (WebRTC Annex-B, fragments, a decode session), a pipe client, a clip ring
15456
+ * (it retains access units continuously, so it needs them built even with
15457
+ * nobody attached), or a restreamer still in Annex-B mode (no camera SDP yet).
15458
+ * The RTSP restream and the WebRTC RTP path are served from the source RTP
15459
+ * and never count. Cheap — four field reads — because it runs per packet.
15460
+ */
15461
+ needsAnnexBPlane() {
15462
+ return this.encodedCallbacks.size > 0 || this.clipBuffer !== null || this.pipeServer.getClientCount() > 0 || !this.rtspRestreamer.rtpPassthroughMode;
15463
+ }
15464
+ /** The consumers that want the plane, for the arm/idle lines. */
15465
+ annexBPlaneConsumers() {
15466
+ return {
15467
+ encodedSubscribers: this.encodedCallbacks.size,
15468
+ clipRing: this.clipBuffer !== null,
15469
+ pipeClients: this.pipeServer.getClientCount(),
15470
+ restreamerAnnexB: !this.rtspRestreamer.rtpPassthroughMode
15471
+ };
15472
+ }
15473
+ /**
15474
+ * Start building access units from the keyframe AU the current packet opens.
15475
+ * The depacketizer and the assembler are clean here (reset when the plane
15476
+ * went idle, or never used), so nothing stale precedes that keyframe.
15477
+ */
15478
+ armAnnexBPlane() {
15479
+ this.annexBPlaneActive = true;
15480
+ this.logger?.info("annex-b plane armed at a keyframe access unit", {
15481
+ tags: { deviceId: this.numericDeviceId },
15482
+ meta: this.annexBPlaneConsumers()
15483
+ });
15484
+ }
15485
+ /**
15486
+ * Stop building access units: the last consumer left. The picture in
15487
+ * progress is dropped, not flushed — there is nobody to hand it to, and a
15488
+ * partial picture must never reach the next consumer's clean start. Logged,
15489
+ * once per transition: a plane that is idle on purpose must not read as a
15490
+ * stream that stopped.
15491
+ */
15492
+ idleAnnexBPlane() {
15493
+ this.annexBPlaneActive = false;
15494
+ this.rtpDepacketizer?.reset();
15495
+ this.annexBAssembler.reset();
15496
+ this.logger?.info("annex-b plane idle — no consumer, NAL reassembly skipped", { tags: { deviceId: this.numericDeviceId } });
15497
+ }
15498
+ /**
15499
+ * One place for the input stats, fed from whichever path owns the pictures:
15500
+ * the source RTP packets while the native reader is up (`onVideoRtp`), the
15501
+ * `EncodedPacket`s otherwise (`pushEncodedPacket`). `bytes` accumulate per
15502
+ * call; `inputFps`, `packetCount` and the IDR interval advance once per
15503
+ * picture — the window is closed on a picture start so a window of N frames
15504
+ * reads N, exactly as it did when every call was one access unit.
15505
+ */
15506
+ recordVideoStats(bytes, pictureStart, keyframe) {
15507
+ this.totalBytes += bytes;
15508
+ this.bytesInWindow += bytes;
15509
+ if (!pictureStart) return;
15510
+ this.packetCount++;
15511
+ this.packetsInWindow++;
15512
+ const now = Date.now();
15513
+ this.lastPacketAt = now;
15514
+ const windowMs = now - this.windowStartMs;
15515
+ if (windowMs >= 2e3) {
15516
+ this.bitrateKbps = Math.round(this.bytesInWindow * 8 / windowMs);
15517
+ this.encodedInputFps = this.packetsInWindow / windowMs * 1e3;
15518
+ this.bytesInWindow = 0;
15519
+ this.packetsInWindow = 0;
15520
+ this.windowStartMs = now;
15521
+ }
15522
+ if (keyframe) {
15523
+ if (this.lastKeyframeMs > 0) this.idrIntervalMs = now - this.lastKeyframeMs;
15524
+ this.lastKeyframeMs = now;
15525
+ }
15526
+ }
15527
+ /**
15144
15528
  * One assembled access unit → one `EncodedPacket` for every Annex-B
15145
15529
  * consumer (prebuffer, clip ring, pipe, WebRTC). A keyframe packet is
15146
15530
  * self-contained (VPS/SPS/PPS + every IDR slice), which is what a decoder
@@ -15561,10 +15945,7 @@ var StreamBroker = class StreamBroker {
15561
15945
  this.annexBAssembler.flush();
15562
15946
  this.annexBAssembler.reset();
15563
15947
  this.rtpAuTracker.reset();
15564
- if (this.pushRtpFlagDepacketizer) {
15565
- this.pushRtpFlagDepacketizer.reset();
15566
- this.pushRtpFlagDepacketizer = null;
15567
- }
15948
+ this.annexBPlaneActive = false;
15568
15949
  this.resetRtpPreBuffer();
15569
15950
  if (this.audioRtpDecoder) {
15570
15951
  this.audioRtpDecoder.flush();
@@ -19282,6 +19663,10 @@ var StreamBrokerManager = class StreamBrokerManager {
19282
19663
  return { killed: broker.killClient(input.channel, input.handle) };
19283
19664
  }
19284
19665
  /** Raw broker instances — internal use only. */
19666
+ /** Live egress transcodes (ffmpeg children) — the `[broker-mem]` probe. */
19667
+ getEgressTranscodeCount() {
19668
+ return this.egressTranscodeManager?.activeCount() ?? 0;
19669
+ }
19285
19670
  getBrokerInstances() {
19286
19671
  return [...this.brokers.values()];
19287
19672
  }
@@ -35154,6 +35539,7 @@ var BrokerWebrtcServer = class {
35154
35539
  }
35155
35540
  };
35156
35541
  this.sessions.set(sessionId, entry);
35542
+ reportConsumerEvent(this.logger, "attach", `webrtc:${entry.consumerKind}`, entry.rebind?.deviceId, this.sessions.size);
35157
35543
  session.onClosed = () => this.cleanupSession(sessionId);
35158
35544
  const locateRecorded = this.locateRecordedSegment;
35159
35545
  const readRecorded = this.readRecordedSegmentBytes;
@@ -36371,6 +36757,7 @@ var BrokerWebrtcServer = class {
36371
36757
  const entry = this.sessions.get(sessionId);
36372
36758
  if (!entry) return;
36373
36759
  this.sessions.delete(sessionId);
36760
+ reportConsumerEvent(this.logger, "detach", `webrtc:${entry.consumerKind}`, entry.rebind?.deviceId, this.sessions.size);
36374
36761
  entry.pendingSwitch?.cancel();
36375
36762
  entry.pendingSwitch = void 0;
36376
36763
  let brokerStillServed = false;
@@ -37092,6 +37479,45 @@ function dialAnswerFromError(err) {
37092
37479
  };
37093
37480
  }
37094
37481
  //#endregion
37482
+ //#region src/stream-broker/broker-mem-report.ts
37483
+ /** Linux only: elsewhere (or on a read error) the native view is simply absent. */
37484
+ async function readProc() {
37485
+ if (process.platform !== "linux") return {
37486
+ status: null,
37487
+ maps: null
37488
+ };
37489
+ const [status, maps] = await Promise.all([(0, node_fs_promises.readFile)("/proc/self/status", "utf8").then(parseProcStatus, () => null), (0, node_fs_promises.readFile)("/proc/self/maps", "utf8").then(parseProcMaps, () => null)]);
37490
+ return {
37491
+ status,
37492
+ maps
37493
+ };
37494
+ }
37495
+ async function emitOnce(deps) {
37496
+ const now = Date.now();
37497
+ const proc = await readProc();
37498
+ const resources = process.getActiveResourcesInfo();
37499
+ const line = formatBrokerMem({
37500
+ samples: deps.manager.getBrokerInstances().map((b) => b.getMemSample(now)),
37501
+ webrtcSessions: deps.webrtcSessions(),
37502
+ egressTranscodes: deps.manager.getEgressTranscodeCount(),
37503
+ childProcesses: countActiveResources(resources, "ProcessWrap"),
37504
+ proc,
37505
+ churn: consumerChurn.drain(),
37506
+ copied: readCopyOwnedTotals()
37507
+ });
37508
+ deps.logger.info(line);
37509
+ }
37510
+ /** Start the five-minute `[broker-mem]` heartbeat; returns its stop function. */
37511
+ function startBrokerMemReport(deps) {
37512
+ const timer = setInterval(() => {
37513
+ emitOnce(deps).catch((err) => {
37514
+ deps.logger.warn("broker-mem probe failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
37515
+ });
37516
+ }, BROKER_MEM_INTERVAL_MS);
37517
+ timer.unref();
37518
+ return () => clearInterval(timer);
37519
+ }
37520
+ //#endregion
37095
37521
  //#region src/stream-broker/event-loop-stall-monitor.ts
37096
37522
  var DEFAULT_INTERVAL_MS = 100;
37097
37523
  var DEFAULT_THRESHOLD_MS = 250;
@@ -37266,6 +37692,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
37266
37692
  * leaks those sockets and wedges the next runner's werift ICE agent
37267
37693
  * (DTLS never connects until a full hub restart). */
37268
37694
  webrtcServer = null;
37695
+ /** Stops the five-minute `[broker-mem]` attribution heartbeat. */
37696
+ stopBrokerMemReport = null;
37269
37697
  /** Names this process's event-loop stalls — see onInitialize. */
37270
37698
  loopStallMonitor = null;
37271
37699
  /** The `log-channels` provider for this process. Stopped on shutdown so a
@@ -37623,6 +38051,11 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
37623
38051
  this.webrtcServer = webrtcServer;
37624
38052
  const webrtcSessionProvider = new WebrtcSessionProvider(webrtcServer, this.brokerManager);
37625
38053
  this.metricsSnapshotTimer = setInterval(() => this.emitBrokerMetricsSnapshot(), BROKER_METRICS_SNAPSHOT_INTERVAL_MS);
38054
+ this.stopBrokerMemReport = startBrokerMemReport({
38055
+ logger: this.ctx.logger,
38056
+ manager: this.brokerManager,
38057
+ webrtcSessions: () => webrtcServer.getSessionCount()
38058
+ });
37626
38059
  const widgetsProvider = { listWidgets: async () => [{
37627
38060
  tab: "device-tab",
37628
38061
  label: "Stream Brokers",
@@ -37727,6 +38160,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
37727
38160
  async onShutdown() {
37728
38161
  this.logChannels?.stop();
37729
38162
  this.logChannels = null;
38163
+ this.stopBrokerMemReport?.();
38164
+ this.stopBrokerMemReport = null;
37730
38165
  this.loopStallMonitor?.stop();
37731
38166
  this.loopStallMonitor = null;
37732
38167
  this.flushProbeSnapshots(Date.now(), true);