@camstack/addon-pipeline 1.2.136 → 1.2.138

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 (41) hide show
  1. package/dist/audio-analyzer/index.js +7 -7
  2. package/dist/audio-analyzer/index.mjs +4 -4
  3. package/dist/detection-pipeline/index.js +61 -23
  4. package/dist/detection-pipeline/index.mjs +53 -15
  5. package/dist/{dist-BzLiQ8Ue.js → dist-BuIP0499.js} +315 -25
  6. package/dist/{dist-C1dXLLQq.mjs → dist-CmEYRK7G.mjs} +304 -26
  7. package/dist/{event-loop-stall-monitor-DjV4Bow7.js → event-loop-stall-monitor-Bef2jXMX.js} +1 -1
  8. package/dist/{event-loop-stall-monitor-CU-xxX5c.mjs → event-loop-stall-monitor-JGTFb0Uv.mjs} +1 -1
  9. package/dist/{lazy-sharp-DxWmhTqk.js → lazy-sharp-DH2eVehm.js} +3 -3
  10. package/dist/motion-wasm/index.js +3 -3
  11. package/dist/motion-wasm/index.mjs +3 -3
  12. package/dist/node-ChdX9lv7.js +721 -0
  13. package/dist/node-J07GBWT3.mjs +690 -0
  14. package/dist/pipeline-runner/index.js +87 -18
  15. package/dist/pipeline-runner/index.mjs +87 -18
  16. package/dist/{process-memory-CZK3D3kM.js → process-memory-CJoZemF4.js} +2 -2
  17. package/dist/{process-memory-CaEwi5Cd.mjs → process-memory-DAKxUb9d.mjs} +2 -2
  18. package/dist/recorder/index.js +39 -7
  19. package/dist/recorder/index.mjs +36 -4
  20. package/dist/{segment-demux-js-BbpqygHL.js → segment-demux-js-BgXbXZnp.js} +1 -1
  21. package/dist/{segment-demux-js-njRO85gd.mjs → segment-demux-js-Ck9ly4bX.mjs} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +115 -5
  23. package/dist/session-decode/decode-worker-child.mjs +115 -5
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CLtMmF1E.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cad8960O.mjs} +3 -3
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-xdmO8wYp.mjs +26 -0
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BrTM_tsb.mjs +26 -0
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-CP7Xhd8J.mjs → hostInit-Gd3OcDIa.mjs} +3 -3
  31. package/dist/stream-broker/index.js +245 -535
  32. package/dist/stream-broker/index.mjs +242 -530
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-DvTZBvh2.js → worker-protocol-Bpz62eyB.js} +2 -1
  35. package/dist/{worker-protocol-DxsVx0G7.mjs → worker-protocol-wMS29PG9.mjs} +2 -1
  36. package/package.json +17 -3
  37. package/dist/addon-utils-CKf8kbwh.js +0 -625
  38. package/dist/addon-utils-CgBCF-mE.mjs +0 -583
  39. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqplWP1i.mjs +0 -26
  40. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DKMXG57X.mjs +0 -26
  41. package/dist/{lazy-sharp-BtT5x-qP.mjs → lazy-sharp-DXsqwpph.mjs} +1 -1
@@ -1,23 +1,24 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $ as logChannelsCapability, $t as EventCategory, At as DeviceFeature, C as RATE_CONTROL_RELAXED, Ct as AUDIO_PRESETS, D as RingBuffer, Dt as isSoftwareDecode, Et as invocationFromEncodeProfile, F as createHwAccelCache, Ft as makeProfileBrokerId, G as egressTranscodeSharingKey, Gt as discriminatedUnion, Ht as _enum, I as createLogChannelsProvider, It as makeSourceBrokerId, Jt as number, K as egressTransportFromRequest, Lt as nodePin, Mt as createEvent, N as batteryCapability, Ot as BaseAddon, P as cameraStreamsCapability, Pt as isEvent, Qt as union, R as declareLogChannel, St as errMsg, Tt as buildFfmpegArgs$1, Ut as array, V as deriveBatteryPresence, Wt as boolean, Xt as record, Yt as object, Zt as string, g as EncodeProfileSchema, i as BatteryStatusSchema, jt as DeviceType, k as addonWidgetsSourceCapability, kt as CAM_PROFILE_ORDER, mt as resolveEgressDecodeHwAccel, qt as literal, tt as maskUrlCredentials, w as RATE_CONTROL_TIGHT, wt as Fmp4BoxSplitter, xt as webrtcSessionCapability, yt as streamBrokerCapability, zt as parseProfileBrokerId } from "../dist-C1dXLLQq.mjs";
2
+ import { $ as loadContributionCapability, $t as string, At as BaseAddon, C as RATE_CONTROL_RELAXED, Ct as errMsg, D as RingBuffer, Dt as invocationFromEncodeProfile, Et as buildFfmpegArgs$1, F as createHwAccelCache, G as egressTranscodeSharingKey, Gt as array, I as createLogChannelsProvider, It as isEvent, K as egressTransportFromRequest, Kt as boolean, Lt as makeProfileBrokerId, Mt as DeviceFeature, N as batteryCapability, Nt as DeviceType, Ot as isSoftwareDecode, P as cameraStreamsCapability, Pt as createEvent, Qt as record, R as declareLogChannel, Rt as makeSourceBrokerId, St as webrtcSessionCapability, V as deriveBatteryPresence, Vt as parseProfileBrokerId, Wt as _enum, Xt as number, Yt as literal, Zt as object, bt as streamBrokerCapability, en as union, et as logChannelsCapability, g as EncodeProfileSchema, ht as resolveEgressDecodeHwAccel, i as BatteryStatusSchema, jt as CAM_PROFILE_ORDER, k as addonWidgetsSourceCapability, kt as BOOT_RECOVERY_BACKOFF_MS, nt as maskUrlCredentials, qt as discriminatedUnion, tn as EventCategory, w as RATE_CONTROL_TIGHT, wt as AUDIO_PRESETS, zt as nodePin } from "../dist-CmEYRK7G.mjs";
3
+ import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-J07GBWT3.mjs";
3
4
  import { t as parseRestreamPath } from "../restream-intent-B4BXZra7.mjs";
4
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
5
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DMolfhsP.mjs";
6
- import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-njRO85gd.mjs";
7
+ import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-Ck9ly4bX.mjs";
7
8
  import { createRequire } from "node:module";
8
9
  import * as crypto$1 from "node:crypto";
9
10
  import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
10
- import { spawn } from "node:child_process";
11
- import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import * as fs from "node:fs";
12
+ import { existsSync } from "node:fs";
12
13
  import * as path$1 from "node:path";
13
14
  import { join } from "node:path";
15
+ import { spawn } from "node:child_process";
16
+ import { readFile } from "node:fs/promises";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
18
  import * as os from "node:os";
15
19
  import { networkInterfaces, tmpdir } from "node:os";
16
- import * as fs from "node:fs";
17
- import { existsSync } from "node:fs";
18
20
  import * as net$1 from "node:net";
19
21
  import net from "node:net";
20
- import { readFile } from "node:fs/promises";
21
22
  import { createSocket } from "node:dgram";
22
23
  import { Socket } from "net";
23
24
  import { once } from "events";
@@ -1261,6 +1262,9 @@ var FfmpegPushReader = class {
1261
1262
  spawnFn;
1262
1263
  callbacks;
1263
1264
  logLabel;
1265
+ claimCost;
1266
+ /** The claim for the child currently running, if any. */
1267
+ costClaim = NO_COST_CLAIM;
1264
1268
  child = null;
1265
1269
  demux = null;
1266
1270
  audioCodec;
@@ -1278,6 +1282,7 @@ var FfmpegPushReader = class {
1278
1282
  this.spawnFn = options.spawnFn ?? spawn;
1279
1283
  this.callbacks = callbacks;
1280
1284
  this.logLabel = logLabel;
1285
+ this.claimCost = options.claimCost;
1281
1286
  }
1282
1287
  /** Spawn ffmpeg and pipe its MPEG-TS stdout into the demux. */
1283
1288
  connect() {
@@ -1299,6 +1304,7 @@ var FfmpegPushReader = class {
1299
1304
  return;
1300
1305
  }
1301
1306
  this.child = child;
1307
+ this.costClaim = this.claimCost?.(child.pid) ?? NO_COST_CLAIM;
1302
1308
  child.stdin?.on("error", () => {});
1303
1309
  child.stdout?.on("data", (chunk) => demux.push(chunk));
1304
1310
  child.stderr?.on("data", (chunk) => this.appendStderr(chunk));
@@ -1392,6 +1398,8 @@ var FfmpegPushReader = class {
1392
1398
  fireTeardown() {
1393
1399
  if (this.toreDown) return;
1394
1400
  this.toreDown = true;
1401
+ this.costClaim.release();
1402
+ this.costClaim = NO_COST_CLAIM;
1395
1403
  this.callbacks.onTeardown?.();
1396
1404
  }
1397
1405
  /** SIGTERM, then SIGKILL if the child has not actually exited (gate on exit
@@ -2234,6 +2242,20 @@ function appendAudioMediaBlock(videoSdp, audioBlock) {
2234
2242
  return `${videoSdp.replace(/[\r\n]+$/, "")}\r\n${audioBlock}\r\n`;
2235
2243
  }
2236
2244
  //#endregion
2245
+ //#region src/stream-broker/rtsp/join-burst-admission.ts
2246
+ /**
2247
+ * Decide whether this session may take this burst.
2248
+ *
2249
+ * Pure and total: no clock, no logger, no restreamer state. The restreamer maps
2250
+ * the result onto its counters and its `continue`.
2251
+ */
2252
+ function admitJoinBurst(input) {
2253
+ if (input.muted && !input.detectionIntent) return !input.fromRing || input.spanMs > input.liveEdgeMaxSpanMs ? "live-edge" : "none";
2254
+ if (!input.detectionIntent && input.spanMs > input.bound.maxSpanMs) return "span";
2255
+ if (input.packets > input.bound.maxPackets) return "packets";
2256
+ return "none";
2257
+ }
2258
+ //#endregion
2237
2259
  //#region src/stream-broker/rtsp/rtsp-types.ts
2238
2260
  /** RTSP method constants. */
2239
2261
  var RTSP_METHODS = [
@@ -2860,8 +2882,6 @@ var RtspRestreamer = class {
2860
2882
  if (ready.length === 0) return;
2861
2883
  const preRollPlans = /* @__PURE__ */ new Map();
2862
2884
  const bound = this.currentPrimeBound();
2863
- const overSpan = spanMs > bound.maxSpanMs;
2864
- const overPackets = primePackets.length > bound.maxPackets;
2865
2885
  /**
2866
2886
  * Resolve (and memoize) the plan for one intent.
2867
2887
  *
@@ -2927,14 +2947,18 @@ var RtspRestreamer = class {
2927
2947
  continue;
2928
2948
  }
2929
2949
  }
2930
- if (session.isMuted() && !detectionIntent) {
2931
- if (!fromRing || spanMs > LIVE_EDGE_MAX_SPAN_MS) {
2932
- this.dropsWithheldMutedLiveEdge++;
2933
- withheld++;
2934
- continue;
2935
- }
2936
- } else if (overSpan || overPackets) {
2937
- if (overSpan) this.dropsWithheldSpan++;
2950
+ const withhold = admitJoinBurst({
2951
+ muted: session.isMuted(),
2952
+ detectionIntent,
2953
+ fromRing,
2954
+ spanMs,
2955
+ packets: primePackets.length,
2956
+ bound,
2957
+ liveEdgeMaxSpanMs: LIVE_EDGE_MAX_SPAN_MS
2958
+ });
2959
+ if (withhold !== "none") {
2960
+ if (withhold === "live-edge") this.dropsWithheldMutedLiveEdge++;
2961
+ else if (withhold === "span") this.dropsWithheldSpan++;
2938
2962
  else this.dropsWithheldPackets++;
2939
2963
  withheld++;
2940
2964
  continue;
@@ -4120,6 +4144,90 @@ function buildAudioCodecCapApi(api) {
4120
4144
  flushEncode: (input) => router.flushEncode.mutate(input)
4121
4145
  };
4122
4146
  }
4147
+ //#endregion
4148
+ //#region src/stream-broker/stream-broker/catalog-pull-backoff.ts
4149
+ /**
4150
+ * CATALOG-PULL BACKOFF — how often the broker asks a camera whose
4151
+ * `stream-catalog` pull keeps failing.
4152
+ *
4153
+ * ## What this exists to stop
4154
+ *
4155
+ * 2026-08-25, device 3629 ("Videocamera porta retro", Reolink). Its Baichuan
4156
+ * session expired under a socket that stayed open, so every
4157
+ * `stream-catalog.getCatalog` answered `buildVideoStreamOptions failed …
4158
+ * responseCode 400`. The reconcile poll asked again 30 s later. And again. For
4159
+ * 35 minutes, ~70 identical failures, each one a login attempt, a 10 s timeout
4160
+ * budget and a `debug` line nobody was reading.
4161
+ *
4162
+ * The fleet was never at risk from that camera — `reconcileAllCatalogs` fans
4163
+ * out with `Promise.allSettled` and `reconcileDeviceCatalogNow` catches its own
4164
+ * pull, so exactly one device was omitted (that isolation is frozen by
4165
+ * `scripts/check-catalog-per-device-isolation.ts`). What was wrong is that the
4166
+ * omission was invisible and the retry was a flat 30 s hammer that could not
4167
+ * have recovered anyway.
4168
+ *
4169
+ * ## The shape
4170
+ *
4171
+ * The ladder is {@link BOOT_RECOVERY_BACKOFF_MS} — the repo's ONE retry
4172
+ * schedule for work that can fail for reasons that pass: two cheap attempts
4173
+ * (15 s, 60 s) for a blip, then the long tail (5 m → 90 m) for a camera that
4174
+ * is genuinely gone.
4175
+ *
4176
+ * ## The one place this DIVERGES from boot recovery, and why
4177
+ *
4178
+ * The boot ladder ENDS: it runs out and manual re-arm is the only path. This
4179
+ * one CLAMPS at the last rung and keeps asking every 90 minutes forever.
4180
+ *
4181
+ * Boot recovery can end because a human is present — a node that just booted
4182
+ * wrong is a node someone is looking at. A camera whose session expired at
4183
+ * 03:00 has nobody. The reconcile poll is the ONLY thing that will ever ask it
4184
+ * again: no event fires when a camera silently starts working, and a device
4185
+ * that never re-enters the catalog is a device with no broker, no recording
4186
+ * and no detection, permanently. Ending the chain would convert a transient
4187
+ * session expiry into a permanent outage — precisely the failure it is meant
4188
+ * to bound. A 90-minute floor is not a spin: it is ~16 pulls a day for one
4189
+ * broken camera, against 2 880 before this.
4190
+ */
4191
+ /**
4192
+ * The rung the ladder settles on. Read off the shared schedule rather than
4193
+ * written down twice; the literal only keeps the expression total for a
4194
+ * hypothetically empty ladder, and is not the clamp.
4195
+ */
4196
+ var LAST_RUNG_MS = BOOT_RECOVERY_BACKOFF_MS[BOOT_RECOVERY_BACKOFF_MS.length - 1] ?? 90 * 6e4;
4197
+ /**
4198
+ * Delay before the next poll-driven attempt, given how many pulls in a row
4199
+ * have now failed (1 = the first failure).
4200
+ *
4201
+ * Clamped at the last rung rather than exhausted — see the module doc.
4202
+ */
4203
+ function catalogPullRetryDelayMs(consecutiveFailures) {
4204
+ return BOOT_RECOVERY_BACKOFF_MS[Math.max(consecutiveFailures, 1) - 1] ?? LAST_RUNG_MS;
4205
+ }
4206
+ /**
4207
+ * Advance the failure memory after one more failed pull.
4208
+ *
4209
+ * @param previous state before this failure, or `undefined` for the first one
4210
+ * @param nowMs wall clock at the moment of the failure
4211
+ */
4212
+ function nextCatalogPullFailureState(previous, nowMs) {
4213
+ const consecutiveFailures = (previous?.consecutiveFailures ?? 0) + 1;
4214
+ return {
4215
+ consecutiveFailures,
4216
+ nextAttemptAtMs: nowMs + catalogPullRetryDelayMs(consecutiveFailures)
4217
+ };
4218
+ }
4219
+ /**
4220
+ * May a POLL-driven pull run for this device right now?
4221
+ *
4222
+ * Only the poll is gated. An event-driven reconcile (device registered,
4223
+ * stream params changed) is EVIDENCE that something moved and always
4224
+ * attempts — a backoff that ignores new information is just a slower way of
4225
+ * being wrong.
4226
+ */
4227
+ function mayAttemptPolledCatalogPull(state, nowMs) {
4228
+ if (state === void 0) return true;
4229
+ return nowMs >= state.nextAttemptAtMs;
4230
+ }
4123
4231
  /**
4124
4232
  * Minimum gap between two re-dials of the same device. A re-dial costs a
4125
4233
  * ~2 s video interruption on every source of the camera; at the 30 s
@@ -4559,506 +4667,6 @@ function pushDimension(into, constraint, requested, known) {
4559
4667
  reason: `source ${known} > requested ${requested}`
4560
4668
  });
4561
4669
  }
4562
- /**
4563
- * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
4564
- * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
4565
- *
4566
- * ## Why a plane and not a callback
4567
- *
4568
- * The operator's requirement for HKSV was explicit: the live fMP4 source built
4569
- * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
4570
- * CamStack as an additional videoclip source alongside the recorder and the NC
4571
- * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
4572
- * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
4573
- * a second ffmpeg child of the same camera. So this is the same shape the
4574
- * broker's other multi-consumer surfaces already have
4575
- * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
4576
- * one producer.
4577
- *
4578
- * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
4579
- * clip source; both are named here so the seam is not re-invented, and neither
4580
- * is built.
4581
- *
4582
- * ## The init segment is RETAINED
4583
- *
4584
- * A subscriber that attaches mid-stream — the clip consumer joining an already
4585
- * running HKSV session, which is the whole dual-use case — receives the
4586
- * retained `ftyp`+`moov` as its first packet and then live fragments. Without
4587
- * retention its fragments are undecodable and the failure looks like a codec
4588
- * problem.
4589
- *
4590
- * ## A slow subscriber is CLOSED, never silently gapped
4591
- *
4592
- * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
4593
- * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
4594
- * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
4595
- * shows a clip that fails to play with nothing anywhere saying why. So a
4596
- * subscription whose queue overflows is ENDED with a reason, loudly, and the
4597
- * other subscriptions are untouched.
4598
- *
4599
- * ## The PREBUFFER (phase 3)
4600
- *
4601
- * HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
4602
- * is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
4603
- * motion edge has none. So the plane optionally retains the last few fragments
4604
- * and replays them to a subscriber that asks for them.
4605
- *
4606
- * Three things this ring gets right, each of which is a measured fact rather
4607
- * than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
4608
- *
4609
- * - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
4610
- * fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
4611
- * window. A time-only bound is a per-camera RAM figure nobody can predict.
4612
- * - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
4613
- * splitter deliberately never computes a fragment's duration (a second
4614
- * opinion about a fact the muxer owns), and a prebuffer cares about how long
4615
- * ago the bytes turned up, which is exactly what arrival time answers.
4616
- * - **A replay is not backlog.** A subscriber taking N retained fragments gets
4617
- * its queue capacity raised by N for them, because closing a subscriber as a
4618
- * slow consumer for the prebuffer it explicitly asked for would be the
4619
- * stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
4620
- * ring of 4, the guaranteed one.
4621
- *
4622
- * ## `isLast`
4623
- *
4624
- * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
4625
- * `isLast` — a generator that finishes without it produces the twelve-second
4626
- * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
4627
- * plane therefore computes it at DELIVERY time: a packet is last when the plane
4628
- * has ended and nothing remains queued behind it. A subscription that ends
4629
- * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
4630
- * the future delegate must not open an HDS stream it cannot feed.
4631
- */
4632
- var DEFAULT_QUEUE_CAPACITY = 4;
4633
- var Fmp4FragmentPlane = class {
4634
- logger;
4635
- prebuffer;
4636
- now;
4637
- subscriptions = /* @__PURE__ */ new Map();
4638
- /** The last init unit seen, handed to every later subscriber. */
4639
- retainedInit = null;
4640
- ended = false;
4641
- /** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
4642
- ring = [];
4643
- ringBytes = 0;
4644
- constructor(logger, prebuffer, now = Date.now) {
4645
- this.logger = logger;
4646
- this.prebuffer = prebuffer;
4647
- this.now = now;
4648
- }
4649
- get subscriberCount() {
4650
- return this.subscriptions.size;
4651
- }
4652
- /** True once {@link end} has been called — no further units are accepted. */
4653
- get isEnded() {
4654
- return this.ended;
4655
- }
4656
- /** What the prebuffer ring holds right now. All zeroes when disabled. */
4657
- prebufferStats() {
4658
- const oldest = this.ring[0];
4659
- return {
4660
- fragments: this.ring.length,
4661
- bytes: this.ringBytes,
4662
- spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
4663
- };
4664
- }
4665
- subscribe(input) {
4666
- const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
4667
- const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
4668
- const sub = {
4669
- id: `fmp4-${randomUUID()}`,
4670
- tag: input.tag,
4671
- subscribedAt: this.now(),
4672
- capacity: requested + replay.length,
4673
- queue: [],
4674
- delivered: 0,
4675
- closedReason: null,
4676
- wake: null,
4677
- iterating: false
4678
- };
4679
- this.subscriptions.set(sub.id, sub);
4680
- if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
4681
- for (const retained of replay) this.enqueue(sub, retained.unit);
4682
- if (this.ended) this.closeSubscription(sub, "ended");
4683
- this.logger?.info("fmp4 plane: subscribed", { meta: {
4684
- subscriptionId: sub.id,
4685
- tag: sub.tag,
4686
- hasRetainedInit: this.retainedInit !== null,
4687
- prebufferFragments: replay.length,
4688
- prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
4689
- } });
4690
- return this.facade(sub);
4691
- }
4692
- /**
4693
- * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
4694
- * emits exactly one per child, and a second means the child was respawned, in
4695
- * which case the old one describes a stream that no longer exists.
4696
- */
4697
- publish(unit) {
4698
- if (this.ended) return;
4699
- if (unit.kind === "init") {
4700
- this.retainedInit = unit;
4701
- this.ring.length = 0;
4702
- this.ringBytes = 0;
4703
- } else this.retain(unit);
4704
- for (const sub of this.subscriptions.values()) {
4705
- if (sub.closedReason !== null) continue;
4706
- this.enqueue(sub, unit);
4707
- }
4708
- }
4709
- /**
4710
- * The producer stopped. Every subscriber drains what it holds; its final
4711
- * packet carries `isLast`, and its generator then completes.
4712
- */
4713
- end(reason = "producer ended") {
4714
- if (this.ended) return;
4715
- this.ended = true;
4716
- this.logger?.info("fmp4 plane: ended", { meta: {
4717
- reason,
4718
- subscribers: this.subscriptions.size
4719
- } });
4720
- for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
4721
- }
4722
- listSubscribers() {
4723
- return [...this.subscriptions.values()].map((s) => ({
4724
- tag: s.tag,
4725
- subscribedAt: s.subscribedAt,
4726
- delivered: s.delivered,
4727
- closedReason: s.closedReason
4728
- }));
4729
- }
4730
- /** End and forget everything. Idempotent. */
4731
- dispose() {
4732
- this.end("disposed");
4733
- this.subscriptions.clear();
4734
- this.retainedInit = null;
4735
- this.ring.length = 0;
4736
- this.ringBytes = 0;
4737
- }
4738
- /**
4739
- * Add one fragment to the ring and evict from the front until BOTH bounds
4740
- * hold. Eviction is oldest-first, which is the one place in this file where
4741
- * dropping is correct: the ring is context, not stream — nobody is mid-decode
4742
- * on it, and a subscriber only ever receives a contiguous tail of it.
4743
- */
4744
- retain(unit) {
4745
- const prebuffer = this.prebuffer;
4746
- if (prebuffer === void 0) return;
4747
- const arrivedAt = this.now();
4748
- this.ring.push({
4749
- unit,
4750
- arrivedAt
4751
- });
4752
- this.ringBytes += unit.data.length;
4753
- const cutoff = arrivedAt - prebuffer.windowMs;
4754
- while (this.ring.length > 0) {
4755
- const oldest = this.ring[0];
4756
- if (oldest === void 0) break;
4757
- const tooOld = oldest.arrivedAt < cutoff;
4758
- const tooBig = this.ringBytes > prebuffer.maxBytes;
4759
- if (!tooOld && !tooBig || this.ring.length === 1) break;
4760
- this.ring.shift();
4761
- this.ringBytes -= oldest.unit.data.length;
4762
- }
4763
- }
4764
- /**
4765
- * The ring as a subscriber should receive it — window applied AT SUBSCRIBE
4766
- * time, not only at publish time. A camera that went quiet keeps its last
4767
- * fragment in the ring indefinitely (see the never-evict-the-newest rule),
4768
- * and replaying a 40-second-old fragment as "prebuffer" would put stale video
4769
- * at the head of a clip iOS presents as the moment of the event.
4770
- */
4771
- trimmedRing() {
4772
- const prebuffer = this.prebuffer;
4773
- if (prebuffer === void 0) return [];
4774
- const cutoff = this.now() - prebuffer.windowMs;
4775
- return this.ring.filter((r) => r.arrivedAt >= cutoff);
4776
- }
4777
- enqueue(sub, unit) {
4778
- if (sub.queue.length >= sub.capacity) {
4779
- this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
4780
- subscriptionId: sub.id,
4781
- tag: sub.tag,
4782
- capacity: sub.capacity,
4783
- delivered: sub.delivered
4784
- } });
4785
- this.closeSubscription(sub, "slow-consumer");
4786
- return;
4787
- }
4788
- sub.queue.push({
4789
- kind: unit.kind,
4790
- data: unit.data,
4791
- sequence: unit.sequence,
4792
- isLast: false
4793
- });
4794
- this.wake(sub);
4795
- }
4796
- closeSubscription(sub, reason) {
4797
- if (sub.closedReason !== null) return;
4798
- sub.closedReason = reason;
4799
- if (reason === "slow-consumer") sub.queue.length = 0;
4800
- this.wake(sub);
4801
- }
4802
- wake(sub) {
4803
- const resume = sub.wake;
4804
- sub.wake = null;
4805
- resume?.();
4806
- }
4807
- facade(sub) {
4808
- const plane = this;
4809
- return {
4810
- id: sub.id,
4811
- tag: sub.tag,
4812
- get delivered() {
4813
- return sub.delivered;
4814
- },
4815
- get closedReason() {
4816
- return sub.closedReason;
4817
- },
4818
- packets: () => plane.iterate(sub),
4819
- release: () => {
4820
- plane.closeSubscription(sub, "released");
4821
- plane.subscriptions.delete(sub.id);
4822
- }
4823
- };
4824
- }
4825
- async *iterate(sub) {
4826
- if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
4827
- sub.iterating = true;
4828
- for (;;) {
4829
- const next = sub.queue.shift();
4830
- if (next === void 0) {
4831
- if (sub.closedReason !== null) return;
4832
- await new Promise((resolve) => {
4833
- sub.wake = resolve;
4834
- });
4835
- continue;
4836
- }
4837
- const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
4838
- sub.delivered += 1;
4839
- yield {
4840
- ...next,
4841
- isLast
4842
- };
4843
- if (isLast) return;
4844
- }
4845
- }
4846
- };
4847
- var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
4848
- /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
4849
- var FRAGMENT_LOG_EVERY = 30;
4850
- var Fmp4FragmentChild = class {
4851
- deps;
4852
- args;
4853
- child = null;
4854
- splitter = new Fmp4BoxSplitter();
4855
- stopped = false;
4856
- unitsOut = 0;
4857
- activeHwAccel = null;
4858
- constructor(deps, args) {
4859
- this.deps = deps;
4860
- this.args = args;
4861
- }
4862
- /** Spawn, and resolve once the INIT segment has been cut out of stdout. */
4863
- async start() {
4864
- const requested = this.args.invocation.decodeHwAccel;
4865
- this.activeHwAccel = requested;
4866
- try {
4867
- await this.spawnAttempt(requested);
4868
- return;
4869
- } catch (err) {
4870
- if (this.stopped) throw err;
4871
- if (requested === null || isSoftwareDecode(requested)) throw err;
4872
- this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
4873
- tags: { deviceId: this.args.deviceId },
4874
- meta: {
4875
- sourceId: this.args.sourceId,
4876
- decodeHwAccel: requested,
4877
- error: errMsg(err)
4878
- }
4879
- });
4880
- this.killChild();
4881
- this.splitter = new Fmp4BoxSplitter();
4882
- this.activeHwAccel = null;
4883
- await this.spawnAttempt(null);
4884
- }
4885
- }
4886
- /** The backend the child ACTUALLY ran with — `null` for software. */
4887
- activeDecodeHwAccel() {
4888
- const value = this.activeHwAccel;
4889
- return value === null || value === "none" || value === "copy" ? null : value;
4890
- }
4891
- /** Kill ffmpeg and end the plane. Idempotent. */
4892
- async stop() {
4893
- if (this.stopped) return;
4894
- this.stopped = true;
4895
- this.killChild();
4896
- this.args.plane.end("the fragment child stopped");
4897
- }
4898
- spawnAttempt(decodeHwAccel) {
4899
- const args = buildFfmpegArgs$1({
4900
- ...this.args.invocation,
4901
- decodeHwAccel,
4902
- sink: {
4903
- kind: "stdout",
4904
- container: "mp4",
4905
- fragmentMs: this.args.fragmentMs
4906
- }
4907
- });
4908
- this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
4909
- tags: { deviceId: this.args.deviceId },
4910
- meta: {
4911
- sourceId: this.args.sourceId,
4912
- fragmentMs: this.args.fragmentMs,
4913
- decodeHwAccel: decodeHwAccel ?? "software",
4914
- argv: args.join(" ")
4915
- }
4916
- });
4917
- return new Promise((resolve, reject) => {
4918
- const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
4919
- "ignore",
4920
- "pipe",
4921
- "pipe"
4922
- ] });
4923
- this.child = child;
4924
- let settled = false;
4925
- /**
4926
- * This attempt FAILED. Set before the kill, because SIGTERM makes the
4927
- * child exit and that exit must not be reported as a death: the retry —
4928
- * or the caller's rejection — already owns what happens next. Without it
4929
- * the timeout path ends the plane the software retry is about to fill,
4930
- * and the consumer sees a stream that stopped for no reason. A "which
4931
- * spawn is current" counter does NOT cover this: the retry has not been
4932
- * spawned when the kill's exit arrives.
4933
- */
4934
- let failed = false;
4935
- /**
4936
- * This attempt is still the live producer: it has not failed (a failure
4937
- * hands ownership to the retry, or to the caller's rejection) and nothing
4938
- * has stopped the child. Those two cover every way an attempt stops being
4939
- * current — `start` only respawns after a rejection.
4940
- */
4941
- const isCurrent = () => !this.stopped && !failed;
4942
- const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
4943
- const settle = (fail) => {
4944
- if (settled) return;
4945
- settled = true;
4946
- clearTimeout(timer);
4947
- if (fail) {
4948
- failed = true;
4949
- reject(fail);
4950
- } else resolve();
4951
- };
4952
- const timer = setTimeout(() => {
4953
- settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
4954
- this.killChild();
4955
- }, timeoutMs);
4956
- timer.unref?.();
4957
- child.stdout?.on("data", (chunk) => {
4958
- for (const unit of this.splitter.push(chunk)) {
4959
- this.unitsOut += 1;
4960
- this.args.plane.publish(unit);
4961
- if (unit.kind === "init") {
4962
- this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
4963
- tags: { deviceId: this.args.deviceId },
4964
- meta: {
4965
- sourceId: this.args.sourceId,
4966
- bytes: unit.data.length
4967
- }
4968
- });
4969
- settle();
4970
- } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
4971
- tags: { deviceId: this.args.deviceId },
4972
- meta: {
4973
- sourceId: this.args.sourceId,
4974
- unitsOut: this.unitsOut,
4975
- bytes: unit.data.length,
4976
- subscribers: this.args.plane.subscriberCount
4977
- }
4978
- });
4979
- }
4980
- const fault = this.splitter.fault;
4981
- if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
4982
- });
4983
- child.stderr?.setEncoding("utf8");
4984
- child.stderr?.on("data", (line) => {
4985
- this.deps.logger.debug("fmp4 fragment child ffmpeg", {
4986
- tags: { deviceId: this.args.deviceId },
4987
- meta: {
4988
- sourceId: this.args.sourceId,
4989
- line: line.trim()
4990
- }
4991
- });
4992
- });
4993
- child.once("error", (err) => {
4994
- if (!settled) {
4995
- settle(err);
4996
- return;
4997
- }
4998
- if (!isCurrent()) return;
4999
- this.args.plane.end("the fragment child errored");
5000
- this.deps.onChildExit?.(err);
5001
- });
5002
- child.once("exit", (code, signal) => {
5003
- if (!settled) {
5004
- settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
5005
- return;
5006
- }
5007
- if (!isCurrent()) return;
5008
- const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
5009
- this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
5010
- tags: { deviceId: this.args.deviceId },
5011
- meta: {
5012
- sourceId: this.args.sourceId,
5013
- code,
5014
- signal,
5015
- unitsOut: this.unitsOut
5016
- }
5017
- });
5018
- this.args.plane.end("the fragment child exited");
5019
- this.deps.onChildExit?.(error);
5020
- });
5021
- });
5022
- }
5023
- /**
5024
- * The byte stream stopped being splittable. Not recoverable — the splitter
5025
- * cannot resynchronise mid-box — so the child is a corpse and every consumer
5026
- * has to be told, loudly, with the reason.
5027
- */
5028
- onFault(reason, wasLive, current, settle) {
5029
- const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
5030
- this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
5031
- tags: { deviceId: this.args.deviceId },
5032
- meta: {
5033
- sourceId: this.args.sourceId,
5034
- unitsOut: this.unitsOut,
5035
- interstitial: this.splitter.discardedInterstitialTypes,
5036
- reason
5037
- }
5038
- });
5039
- this.killChild();
5040
- settle(error);
5041
- if (wasLive && current) {
5042
- this.args.plane.end("the fragment child produced unsplittable output");
5043
- this.deps.onChildExit?.(error);
5044
- }
5045
- }
5046
- killChild() {
5047
- const child = this.child;
5048
- this.child = null;
5049
- if (child && !child.killed) try {
5050
- child.kill("SIGTERM");
5051
- } catch (err) {
5052
- this.deps.logger.warn("fmp4 fragment child: kill error", {
5053
- tags: { deviceId: this.args.deviceId },
5054
- meta: {
5055
- sourceId: this.args.sourceId,
5056
- error: errMsg(err)
5057
- }
5058
- });
5059
- }
5060
- }
5061
- };
5062
4670
  //#endregion
5063
4671
  //#region src/stream-broker/stream-broker/rtsp-host.ts
5064
4672
  /**
@@ -5263,6 +4871,7 @@ var TranscodeEgress = class {
5263
4871
  ] });
5264
4872
  this.child = child;
5265
4873
  this.startedAtMs = Date.now();
4874
+ const costClaim = this.deps.claimCost?.(child.pid) ?? NO_COST_CLAIM;
5266
4875
  let settled = false;
5267
4876
  const timeoutMs = this.deps.firstDataTimeoutMs ?? DEFAULT_FIRST_DATA_TIMEOUT_MS;
5268
4877
  const timer = setTimeout(() => {
@@ -5299,12 +4908,14 @@ var TranscodeEgress = class {
5299
4908
  } });
5300
4909
  });
5301
4910
  child.once("error", (err) => {
4911
+ costClaim.release();
5302
4912
  if (settled) return;
5303
4913
  settled = true;
5304
4914
  clearTimeout(timer);
5305
4915
  reject(err);
5306
4916
  });
5307
4917
  child.once("exit", (code, signal) => {
4918
+ costClaim.release();
5308
4919
  if (settled) {
5309
4920
  if (this.stopped) return;
5310
4921
  const error = /* @__PURE__ */ new Error(`transcode egress: ffmpeg exited while live (code=${code} signal=${signal})`);
@@ -5865,7 +5476,13 @@ var EgressTranscodeManager = class {
5865
5476
  ffmpegBinaryPath: this.opts.ffmpegConfig().binaryPath,
5866
5477
  spawnFn: this.opts.spawnFn ?? spawn,
5867
5478
  acquireChannel: () => channel,
5868
- ...spec.onDied ? { onChildExit: spec.onDied } : {}
5479
+ ...spec.onDied ? { onChildExit: spec.onDied } : {},
5480
+ claimCost: (pid) => this.opts.costRegistry?.claim({
5481
+ role: "transcode",
5482
+ deviceId: spec.deviceId,
5483
+ unit: spec.brokerId,
5484
+ pid
5485
+ }) ?? NO_COST_CLAIM
5869
5486
  }, {
5870
5487
  brokerId: spec.brokerId,
5871
5488
  targetCodec: spec.targetCodec,
@@ -11371,6 +10988,14 @@ var StreamBroker = class StreamBroker {
11371
10988
  * and a line without the tag cannot be grouped to answer it.
11372
10989
  */
11373
10990
  numericDeviceId;
10991
+ /**
10992
+ * Where this broker records the ffmpeg ingest it spawns, so the stream-broker
10993
+ * addon reports its OWN per-camera cost through `load-contribution` instead
10994
+ * of leaving an observer to join a pid against a camera. Null until the
10995
+ * manager injects it; a broker without one still streams, and its ffmpeg is
10996
+ * simply unclaimed in the node's process snapshot.
10997
+ */
10998
+ costRegistry = null;
11374
10999
  _status = "idle";
11375
11000
  source;
11376
11001
  /**
@@ -11904,6 +11529,30 @@ var StreamBroker = class StreamBroker {
11904
11529
  this.ffmpegBinaryPath = binaryPath;
11905
11530
  }
11906
11531
  /**
11532
+ * Inject the addon's child-cost registry. Injected rather than constructed
11533
+ * here because the registry is per-ADDON-PROCESS (it is what the addon's
11534
+ * `load-contribution` provider reads) while a broker is per-camera.
11535
+ */
11536
+ setCostRegistry(registry) {
11537
+ this.costRegistry = registry;
11538
+ }
11539
+ /**
11540
+ * Claim one ffmpeg ingest generation for this camera, or nothing when the
11541
+ * camera cannot be named. Never a placeholder device id: an unnamed
11542
+ * per-camera claim is indistinguishable from a shared cost and would quietly
11543
+ * turn one camera's ffmpeg into everybody's.
11544
+ */
11545
+ claimIngestCost(pid) {
11546
+ const deviceId = this.numericDeviceId;
11547
+ if (this.costRegistry === null || deviceId === void 0) return NO_COST_CLAIM;
11548
+ return this.costRegistry.claim({
11549
+ role: "streaming",
11550
+ deviceId,
11551
+ unit: this.deviceId,
11552
+ pid
11553
+ });
11554
+ }
11555
+ /**
11907
11556
  * Inject a manager-owned closure that derives the current
11908
11557
  * `StreamSource` from the live cam-stream registry. Every dial
11909
11558
  * attempt (initial start, scheduled reconnect, demand-driven resume)
@@ -13519,7 +13168,8 @@ var StreamBroker = class StreamBroker {
13519
13168
  const reader = new FfmpegSourceReader({
13520
13169
  ffmpegPath: this.ffmpegBinaryPath,
13521
13170
  args,
13522
- ...this.logger ? { logger: this.logger.child("ffmpeg-source") } : {}
13171
+ ...this.logger ? { logger: this.logger.child("ffmpeg-source") } : {},
13172
+ claimCost: (pid) => this.claimIngestCost(pid)
13523
13173
  }, {
13524
13174
  onVideoCodec: (codec) => {
13525
13175
  this.detectedCodec = codec;
@@ -15092,6 +14742,13 @@ var StreamBrokerManager = class StreamBrokerManager {
15092
14742
  deviceOverridePersister = null;
15093
14743
  profileMapPersister = null;
15094
14744
  deviceCatalogOperations = /* @__PURE__ */ new Map();
14745
+ /**
14746
+ * Devices whose `stream-catalog` pull is currently failing, and when the
14747
+ * POLL may ask again. Absent = healthy; an entry is deleted the moment a
14748
+ * pull succeeds. See `catalog-pull-backoff.ts` for the ladder and for the
14749
+ * outage that put it here.
14750
+ */
14751
+ catalogPullFailures = /* @__PURE__ */ new Map();
15095
14752
  eventBus = null;
15096
14753
  /**
15097
14754
  * Per-broker stream health flag set by the watchdog. `undefined`
@@ -15297,6 +14954,8 @@ var StreamBrokerManager = class StreamBrokerManager {
15297
14954
  * that reclaims a refcount whose holder died without releasing.
15298
14955
  */
15299
14956
  egressTranscodeManager = null;
14957
+ /** See {@link StreamBrokerManager.setCostRegistry}. */
14958
+ costRegistry = null;
15300
14959
  /**
15301
14960
  * Test/injection seam for the egress primitive's child. Production leaves it
15302
14961
  * null and `EgressTranscodeManager` builds a real `TranscodeEgress` /
@@ -15339,6 +14998,15 @@ var StreamBrokerManager = class StreamBrokerManager {
15339
14998
  this.rtspProvider = new RtspRestreamProviderImpl(this.rtspServer, logger.child("rtsp-restream"));
15340
14999
  }
15341
15000
  /**
15001
+ * The addon's child-cost registry, threaded down to every broker and to the
15002
+ * egress transcode manager so each spawn site claims its OWN child. Injected
15003
+ * (not constructed) because it is per-addon-process and is what the addon's
15004
+ * `load-contribution` provider reads.
15005
+ */
15006
+ setCostRegistry(registry) {
15007
+ this.costRegistry = registry;
15008
+ }
15009
+ /**
15342
15010
  * Lazily create the transcode manager wired with closures rather than
15343
15011
  * direct refs so it only sees the surface it needs.
15344
15012
  */
@@ -15406,6 +15074,7 @@ var StreamBrokerManager = class StreamBrokerManager {
15406
15074
  return entry ? { url: entry.url } : null;
15407
15075
  },
15408
15076
  ffmpegConfig: () => this.ffmpegConfig,
15077
+ ...this.costRegistry === null ? {} : { costRegistry: this.costRegistry },
15409
15078
  resolveDecodeHwAccel: (deviceId, override) => this.resolveEgressHwAccel(deviceId, override),
15410
15079
  acquireChannel: (brokerId) => {
15411
15080
  const restreamer = new RtspRestreamer(brokerId);
@@ -16631,26 +16300,54 @@ var StreamBrokerManager = class StreamBrokerManager {
16631
16300
  /**
16632
16301
  * Pull one camera's `stream-catalog` and reconcile its cam-streams: upsert
16633
16302
  * every descriptor, retract any cam-stream no longer in the catalog. A pull
16634
- * that throws (provider mid-restart, no `stream-catalog` cap) is skipped
16635
- * the poll retries. An EMPTY catalog is also skipped rather than tearing
16636
- * down (a provider probe can transiently return none); genuine removal flows
16637
- * through the device-unregistered path.
16303
+ * that throws (provider mid-restart, no `stream-catalog` cap, a camera whose
16304
+ * session died) omits THIS DEVICE ONLY — it is logged at `warn` with the
16305
+ * device's id and re-asked on the `catalog-pull-backoff.ts` ladder. An EMPTY
16306
+ * catalog is also skipped rather than tearing down (a provider probe can
16307
+ * transiently return none); genuine removal flows through the
16308
+ * device-unregistered path.
16309
+ *
16310
+ * `trigger` decides whether the backoff applies — see
16311
+ * {@link CatalogReconcileTrigger}. It defaults to `'event'` (always attempt)
16312
+ * so only the caller that actually repeats forever pays the throttle.
16638
16313
  */
16639
- async reconcileDeviceCatalog(deviceId) {
16640
- await this.runDeviceCatalogOperation(deviceId, () => this.reconcileDeviceCatalogNow(deviceId));
16314
+ async reconcileDeviceCatalog(deviceId, trigger = "event") {
16315
+ await this.runDeviceCatalogOperation(deviceId, () => this.reconcileDeviceCatalogNow(deviceId, trigger));
16641
16316
  }
16642
- async reconcileDeviceCatalogNow(deviceId) {
16317
+ async reconcileDeviceCatalogNow(deviceId, trigger) {
16643
16318
  if (!this.api) return;
16319
+ const failure = this.catalogPullFailures.get(deviceId);
16320
+ if (trigger === "poll" && !mayAttemptPolledCatalogPull(failure, Date.now())) {
16321
+ this.logger.debug("reconcileDeviceCatalog: skipped — device is on catalog-pull backoff", {
16322
+ tags: { deviceId },
16323
+ meta: {
16324
+ consecutiveFailures: failure?.consecutiveFailures ?? 0,
16325
+ nextAttemptInMs: Math.max(0, (failure?.nextAttemptAtMs ?? 0) - Date.now())
16326
+ }
16327
+ });
16328
+ return;
16329
+ }
16644
16330
  let descriptors;
16645
16331
  try {
16646
16332
  descriptors = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId) ?? [];
16647
16333
  } catch (err) {
16648
- this.logger.debug("reconcileDeviceCatalog: stream-catalog pull failed — will retry", {
16334
+ const next = nextCatalogPullFailureState(this.catalogPullFailures.get(deviceId), Date.now());
16335
+ this.catalogPullFailures.set(deviceId, next);
16336
+ this.logger.warn("stream-catalog pull failed — device omitted from this catalog reconcile", {
16649
16337
  tags: { deviceId },
16650
- meta: { error: errMsg(err) }
16338
+ meta: {
16339
+ trigger,
16340
+ consecutiveFailures: next.consecutiveFailures,
16341
+ retryInMs: catalogPullRetryDelayMs(next.consecutiveFailures),
16342
+ error: errMsg(err)
16343
+ }
16651
16344
  });
16652
16345
  return;
16653
16346
  }
16347
+ if (this.catalogPullFailures.delete(deviceId)) this.logger.info("stream-catalog pull recovered — device is back in the catalog", {
16348
+ tags: { deviceId },
16349
+ meta: { descriptors: descriptors.length }
16350
+ });
16654
16351
  if (descriptors.length === 0) return;
16655
16352
  const keep = new Set(descriptors.map((d) => d.camStreamId));
16656
16353
  await this.publishCatalog(deviceId, descriptors);
@@ -16685,10 +16382,11 @@ var StreamBrokerManager = class StreamBrokerManager {
16685
16382
  await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
16686
16383
  } else this.disabledDevices.delete(d.id);
16687
16384
  const live = cameras.filter((d) => !d.disabled);
16688
- await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id)));
16385
+ await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id, "poll")));
16689
16386
  await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
16690
16387
  const liveIds = new Set(live.map((d) => d.id));
16691
16388
  for (const deviceId of [...this.audioPlaneState.keys()]) if (!liveIds.has(deviceId)) this.audioPlaneState.delete(deviceId);
16389
+ for (const deviceId of [...this.catalogPullFailures.keys()]) if (!liveIds.has(deviceId)) this.catalogPullFailures.delete(deviceId);
16692
16390
  }
16693
16391
  /**
16694
16392
  * ONE device's audio-plane reconcile pass. See `audio-plane-reconcile.ts`
@@ -16830,6 +16528,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16830
16528
  await this.runDeviceCatalogOperation(deviceId, () => this.retractDeviceNow(deviceId, opts));
16831
16529
  }
16832
16530
  async retractDeviceNow(deviceId, opts) {
16531
+ this.catalogPullFailures.delete(deviceId);
16833
16532
  const existing = this.cameraStreams.get(deviceId);
16834
16533
  if (existing) for (const camStreamId of Array.from(existing.keys())) await this.retractCameraStream({
16835
16534
  deviceId,
@@ -19056,6 +18755,7 @@ var StreamBrokerManager = class StreamBrokerManager {
19056
18755
  const broker = new StreamBroker(brokerId, this.buildDecoderCapApi(deviceId), brokerLogger, buildAudioCodecCapApi(this.api), this.localNodeId);
19057
18756
  broker.setRestreamUrlResolver(() => this.rtspProvider.getEntry(brokerId)?.mutedUrl ?? null);
19058
18757
  broker.setFfmpegBinaryPath(this.ffmpegConfig.binaryPath);
18758
+ if (this.costRegistry !== null) broker.setCostRegistry(this.costRegistry);
19059
18759
  this.applyPreBufferConfig(broker, deviceId, camStreamId);
19060
18760
  this.applyAudioMuteConfig(broker, deviceId);
19061
18761
  this.applyJoinBurstBound(broker, deviceId, camStreamId);
@@ -29502,6 +29202,12 @@ var StreamBrokerAddon = class extends BaseAddon {
29502
29202
  * diagnostic timer never keeps a runner from exiting. */
29503
29203
  logChannels = null;
29504
29204
  /**
29205
+ * The ffmpeg children this addon started, claimed at their spawn sites and
29206
+ * released at their exits. One registry per PROCESS: it dies with the
29207
+ * process, which is correct — those children were this process's children.
29208
+ */
29209
+ costRegistry = new ChildCostRegistry();
29210
+ /**
29505
29211
  * Snapshot-equality cache for the broker-metrics emit. Each
29506
29212
  * broker emits a stats snapshot every BROKER_METRICS_SNAPSHOT_-
29507
29213
  * INTERVAL_MS; for an idle broker (status=streaming, fps=0,
@@ -29587,11 +29293,16 @@ var StreamBrokerAddon = class extends BaseAddon {
29587
29293
  capability: logChannelsCapability,
29588
29294
  provider: this.logChannels
29589
29295
  };
29296
+ const loadContributionRegistration = {
29297
+ capability: loadContributionCapability,
29298
+ provider: { list: () => this.costRegistry.contributions() }
29299
+ };
29590
29300
  this.loopStallMonitor = new EventLoopStallMonitor({ onStall: (stallMs) => {
29591
29301
  this.ctx?.logger?.warn?.("event-loop stall — every session in this process froze", { meta: { stallMs } });
29592
29302
  } });
29593
29303
  this.loopStallMonitor.start();
29594
29304
  this.brokerManager = new StreamBrokerManager(void 0, this.ctx.logger);
29305
+ this.brokerManager.setCostRegistry(this.costRegistry);
29595
29306
  this.brokerManager.setDefaultPreBufferSec(this.config.defaultPreBufferSec);
29596
29307
  this.brokerManager.setDefaultJoinBurstMaxSpanMs(this.config.joinBurstMaxSpanMs);
29597
29308
  this.brokerManager.setDefaultPreRollMs(this.config.preRollMs);
@@ -29944,7 +29655,8 @@ var StreamBrokerAddon = class extends BaseAddon {
29944
29655
  capability: addonWidgetsSourceCapability,
29945
29656
  provider: widgetsProvider
29946
29657
  },
29947
- logChannelsRegistration
29658
+ logChannelsRegistration,
29659
+ loadContributionRegistration
29948
29660
  ];
29949
29661
  }
29950
29662
  async onShutdown() {