@camstack/addon-pipeline 1.2.137 → 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 (38) hide show
  1. package/dist/audio-analyzer/index.js +5 -5
  2. package/dist/audio-analyzer/index.mjs +3 -3
  3. package/dist/detection-pipeline/index.js +12 -12
  4. package/dist/detection-pipeline/index.mjs +5 -5
  5. package/dist/{dist-4EMKvxCp.js → dist-BuIP0499.js} +40 -0
  6. package/dist/{dist-g0Or5_pR.mjs → dist-CmEYRK7G.mjs} +35 -1
  7. package/dist/{event-loop-stall-monitor-DDm07Ei6.js → event-loop-stall-monitor-Bef2jXMX.js} +1 -1
  8. package/dist/{event-loop-stall-monitor-CskL9Lgu.mjs → event-loop-stall-monitor-JGTFb0Uv.mjs} +1 -1
  9. package/dist/{lazy-sharp-BlA9PxWk.js → lazy-sharp-DH2eVehm.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-BOHn8gpk.js → node-ChdX9lv7.js} +1 -1
  13. package/dist/{node-Bo1g8hLO.mjs → node-J07GBWT3.mjs} +1 -1
  14. package/dist/pipeline-runner/index.js +55 -12
  15. package/dist/pipeline-runner/index.mjs +54 -11
  16. package/dist/{process-memory-C3084DOt.js → process-memory-CJoZemF4.js} +1 -1
  17. package/dist/{process-memory-DmrHWybb.mjs → process-memory-DAKxUb9d.mjs} +1 -1
  18. package/dist/recorder/index.js +6 -6
  19. package/dist/recorder/index.mjs +3 -3
  20. package/dist/{segment-demux-js-BrQ_A0oi.js → segment-demux-js-BgXbXZnp.js} +1 -1
  21. package/dist/{segment-demux-js-C-a2pghM.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 +114 -4
  24. package/dist/stream-broker/_stub.js +1 -1
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-AH6OJ3hJ.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/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-XNyn-FTM.mjs → hostInit-Gd3OcDIa.mjs} +3 -3
  30. package/dist/stream-broker/index.js +160 -23
  31. package/dist/stream-broker/index.mjs +160 -23
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/dist/{worker-protocol-BLfaD5uC.js → worker-protocol-Bpz62eyB.js} +2 -1
  34. package/dist/{worker-protocol-vc8WWhjx.mjs → worker-protocol-wMS29PG9.mjs} +2 -1
  35. package/package.json +5 -3
  36. package/dist/addon-utils-C6b5Tnia.js +0 -625
  37. package/dist/addon-utils-C8KTuPdE.mjs +0 -583
  38. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CQyiEd0l.mjs +0 -26
@@ -2,12 +2,12 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-4EMKvxCp.js");
6
- const require_node = require("../node-BOHn8gpk.js");
5
+ const require_dist = require("../dist-BuIP0499.js");
6
+ const require_node = require("../node-ChdX9lv7.js");
7
7
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
8
8
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
9
9
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
10
- const require_segment_demux_js = require("../segment-demux-js-BrQ_A0oi.js");
10
+ const require_segment_demux_js = require("../segment-demux-js-BgXbXZnp.js");
11
11
  let node_crypto = require("node:crypto");
12
12
  node_crypto = require_dist.__toESM(node_crypto);
13
13
  let node_fs = require("node:fs");
@@ -2245,6 +2245,20 @@ function appendAudioMediaBlock(videoSdp, audioBlock) {
2245
2245
  return `${videoSdp.replace(/[\r\n]+$/, "")}\r\n${audioBlock}\r\n`;
2246
2246
  }
2247
2247
  //#endregion
2248
+ //#region src/stream-broker/rtsp/join-burst-admission.ts
2249
+ /**
2250
+ * Decide whether this session may take this burst.
2251
+ *
2252
+ * Pure and total: no clock, no logger, no restreamer state. The restreamer maps
2253
+ * the result onto its counters and its `continue`.
2254
+ */
2255
+ function admitJoinBurst(input) {
2256
+ if (input.muted && !input.detectionIntent) return !input.fromRing || input.spanMs > input.liveEdgeMaxSpanMs ? "live-edge" : "none";
2257
+ if (!input.detectionIntent && input.spanMs > input.bound.maxSpanMs) return "span";
2258
+ if (input.packets > input.bound.maxPackets) return "packets";
2259
+ return "none";
2260
+ }
2261
+ //#endregion
2248
2262
  //#region src/stream-broker/rtsp/rtsp-types.ts
2249
2263
  /** RTSP method constants. */
2250
2264
  var RTSP_METHODS = [
@@ -2871,8 +2885,6 @@ var RtspRestreamer = class {
2871
2885
  if (ready.length === 0) return;
2872
2886
  const preRollPlans = /* @__PURE__ */ new Map();
2873
2887
  const bound = this.currentPrimeBound();
2874
- const overSpan = spanMs > bound.maxSpanMs;
2875
- const overPackets = primePackets.length > bound.maxPackets;
2876
2888
  /**
2877
2889
  * Resolve (and memoize) the plan for one intent.
2878
2890
  *
@@ -2938,14 +2950,18 @@ var RtspRestreamer = class {
2938
2950
  continue;
2939
2951
  }
2940
2952
  }
2941
- if (session.isMuted() && !detectionIntent) {
2942
- if (!fromRing || spanMs > LIVE_EDGE_MAX_SPAN_MS) {
2943
- this.dropsWithheldMutedLiveEdge++;
2944
- withheld++;
2945
- continue;
2946
- }
2947
- } else if (overSpan || overPackets) {
2948
- if (overSpan) this.dropsWithheldSpan++;
2953
+ const withhold = admitJoinBurst({
2954
+ muted: session.isMuted(),
2955
+ detectionIntent,
2956
+ fromRing,
2957
+ spanMs,
2958
+ packets: primePackets.length,
2959
+ bound,
2960
+ liveEdgeMaxSpanMs: LIVE_EDGE_MAX_SPAN_MS
2961
+ });
2962
+ if (withhold !== "none") {
2963
+ if (withhold === "live-edge") this.dropsWithheldMutedLiveEdge++;
2964
+ else if (withhold === "span") this.dropsWithheldSpan++;
2949
2965
  else this.dropsWithheldPackets++;
2950
2966
  withheld++;
2951
2967
  continue;
@@ -4131,6 +4147,90 @@ function buildAudioCodecCapApi(api) {
4131
4147
  flushEncode: (input) => router.flushEncode.mutate(input)
4132
4148
  };
4133
4149
  }
4150
+ //#endregion
4151
+ //#region src/stream-broker/stream-broker/catalog-pull-backoff.ts
4152
+ /**
4153
+ * CATALOG-PULL BACKOFF — how often the broker asks a camera whose
4154
+ * `stream-catalog` pull keeps failing.
4155
+ *
4156
+ * ## What this exists to stop
4157
+ *
4158
+ * 2026-08-25, device 3629 ("Videocamera porta retro", Reolink). Its Baichuan
4159
+ * session expired under a socket that stayed open, so every
4160
+ * `stream-catalog.getCatalog` answered `buildVideoStreamOptions failed …
4161
+ * responseCode 400`. The reconcile poll asked again 30 s later. And again. For
4162
+ * 35 minutes, ~70 identical failures, each one a login attempt, a 10 s timeout
4163
+ * budget and a `debug` line nobody was reading.
4164
+ *
4165
+ * The fleet was never at risk from that camera — `reconcileAllCatalogs` fans
4166
+ * out with `Promise.allSettled` and `reconcileDeviceCatalogNow` catches its own
4167
+ * pull, so exactly one device was omitted (that isolation is frozen by
4168
+ * `scripts/check-catalog-per-device-isolation.ts`). What was wrong is that the
4169
+ * omission was invisible and the retry was a flat 30 s hammer that could not
4170
+ * have recovered anyway.
4171
+ *
4172
+ * ## The shape
4173
+ *
4174
+ * The ladder is {@link BOOT_RECOVERY_BACKOFF_MS} — the repo's ONE retry
4175
+ * schedule for work that can fail for reasons that pass: two cheap attempts
4176
+ * (15 s, 60 s) for a blip, then the long tail (5 m → 90 m) for a camera that
4177
+ * is genuinely gone.
4178
+ *
4179
+ * ## The one place this DIVERGES from boot recovery, and why
4180
+ *
4181
+ * The boot ladder ENDS: it runs out and manual re-arm is the only path. This
4182
+ * one CLAMPS at the last rung and keeps asking every 90 minutes forever.
4183
+ *
4184
+ * Boot recovery can end because a human is present — a node that just booted
4185
+ * wrong is a node someone is looking at. A camera whose session expired at
4186
+ * 03:00 has nobody. The reconcile poll is the ONLY thing that will ever ask it
4187
+ * again: no event fires when a camera silently starts working, and a device
4188
+ * that never re-enters the catalog is a device with no broker, no recording
4189
+ * and no detection, permanently. Ending the chain would convert a transient
4190
+ * session expiry into a permanent outage — precisely the failure it is meant
4191
+ * to bound. A 90-minute floor is not a spin: it is ~16 pulls a day for one
4192
+ * broken camera, against 2 880 before this.
4193
+ */
4194
+ /**
4195
+ * The rung the ladder settles on. Read off the shared schedule rather than
4196
+ * written down twice; the literal only keeps the expression total for a
4197
+ * hypothetically empty ladder, and is not the clamp.
4198
+ */
4199
+ var LAST_RUNG_MS = require_dist.BOOT_RECOVERY_BACKOFF_MS[require_dist.BOOT_RECOVERY_BACKOFF_MS.length - 1] ?? 90 * 6e4;
4200
+ /**
4201
+ * Delay before the next poll-driven attempt, given how many pulls in a row
4202
+ * have now failed (1 = the first failure).
4203
+ *
4204
+ * Clamped at the last rung rather than exhausted — see the module doc.
4205
+ */
4206
+ function catalogPullRetryDelayMs(consecutiveFailures) {
4207
+ return require_dist.BOOT_RECOVERY_BACKOFF_MS[Math.max(consecutiveFailures, 1) - 1] ?? LAST_RUNG_MS;
4208
+ }
4209
+ /**
4210
+ * Advance the failure memory after one more failed pull.
4211
+ *
4212
+ * @param previous state before this failure, or `undefined` for the first one
4213
+ * @param nowMs wall clock at the moment of the failure
4214
+ */
4215
+ function nextCatalogPullFailureState(previous, nowMs) {
4216
+ const consecutiveFailures = (previous?.consecutiveFailures ?? 0) + 1;
4217
+ return {
4218
+ consecutiveFailures,
4219
+ nextAttemptAtMs: nowMs + catalogPullRetryDelayMs(consecutiveFailures)
4220
+ };
4221
+ }
4222
+ /**
4223
+ * May a POLL-driven pull run for this device right now?
4224
+ *
4225
+ * Only the poll is gated. An event-driven reconcile (device registered,
4226
+ * stream params changed) is EVIDENCE that something moved and always
4227
+ * attempts — a backoff that ignores new information is just a slower way of
4228
+ * being wrong.
4229
+ */
4230
+ function mayAttemptPolledCatalogPull(state, nowMs) {
4231
+ if (state === void 0) return true;
4232
+ return nowMs >= state.nextAttemptAtMs;
4233
+ }
4134
4234
  /**
4135
4235
  * Minimum gap between two re-dials of the same device. A re-dial costs a
4136
4236
  * ~2 s video interruption on every source of the camera; at the 30 s
@@ -14645,6 +14745,13 @@ var StreamBrokerManager = class StreamBrokerManager {
14645
14745
  deviceOverridePersister = null;
14646
14746
  profileMapPersister = null;
14647
14747
  deviceCatalogOperations = /* @__PURE__ */ new Map();
14748
+ /**
14749
+ * Devices whose `stream-catalog` pull is currently failing, and when the
14750
+ * POLL may ask again. Absent = healthy; an entry is deleted the moment a
14751
+ * pull succeeds. See `catalog-pull-backoff.ts` for the ladder and for the
14752
+ * outage that put it here.
14753
+ */
14754
+ catalogPullFailures = /* @__PURE__ */ new Map();
14648
14755
  eventBus = null;
14649
14756
  /**
14650
14757
  * Per-broker stream health flag set by the watchdog. `undefined`
@@ -16196,26 +16303,54 @@ var StreamBrokerManager = class StreamBrokerManager {
16196
16303
  /**
16197
16304
  * Pull one camera's `stream-catalog` and reconcile its cam-streams: upsert
16198
16305
  * every descriptor, retract any cam-stream no longer in the catalog. A pull
16199
- * that throws (provider mid-restart, no `stream-catalog` cap) is skipped
16200
- * the poll retries. An EMPTY catalog is also skipped rather than tearing
16201
- * down (a provider probe can transiently return none); genuine removal flows
16202
- * through the device-unregistered path.
16306
+ * that throws (provider mid-restart, no `stream-catalog` cap, a camera whose
16307
+ * session died) omits THIS DEVICE ONLY — it is logged at `warn` with the
16308
+ * device's id and re-asked on the `catalog-pull-backoff.ts` ladder. An EMPTY
16309
+ * catalog is also skipped rather than tearing down (a provider probe can
16310
+ * transiently return none); genuine removal flows through the
16311
+ * device-unregistered path.
16312
+ *
16313
+ * `trigger` decides whether the backoff applies — see
16314
+ * {@link CatalogReconcileTrigger}. It defaults to `'event'` (always attempt)
16315
+ * so only the caller that actually repeats forever pays the throttle.
16203
16316
  */
16204
- async reconcileDeviceCatalog(deviceId) {
16205
- await this.runDeviceCatalogOperation(deviceId, () => this.reconcileDeviceCatalogNow(deviceId));
16317
+ async reconcileDeviceCatalog(deviceId, trigger = "event") {
16318
+ await this.runDeviceCatalogOperation(deviceId, () => this.reconcileDeviceCatalogNow(deviceId, trigger));
16206
16319
  }
16207
- async reconcileDeviceCatalogNow(deviceId) {
16320
+ async reconcileDeviceCatalogNow(deviceId, trigger) {
16208
16321
  if (!this.api) return;
16322
+ const failure = this.catalogPullFailures.get(deviceId);
16323
+ if (trigger === "poll" && !mayAttemptPolledCatalogPull(failure, Date.now())) {
16324
+ this.logger.debug("reconcileDeviceCatalog: skipped — device is on catalog-pull backoff", {
16325
+ tags: { deviceId },
16326
+ meta: {
16327
+ consecutiveFailures: failure?.consecutiveFailures ?? 0,
16328
+ nextAttemptInMs: Math.max(0, (failure?.nextAttemptAtMs ?? 0) - Date.now())
16329
+ }
16330
+ });
16331
+ return;
16332
+ }
16209
16333
  let descriptors;
16210
16334
  try {
16211
16335
  descriptors = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId) ?? [];
16212
16336
  } catch (err) {
16213
- this.logger.debug("reconcileDeviceCatalog: stream-catalog pull failed — will retry", {
16337
+ const next = nextCatalogPullFailureState(this.catalogPullFailures.get(deviceId), Date.now());
16338
+ this.catalogPullFailures.set(deviceId, next);
16339
+ this.logger.warn("stream-catalog pull failed — device omitted from this catalog reconcile", {
16214
16340
  tags: { deviceId },
16215
- meta: { error: require_dist.errMsg(err) }
16341
+ meta: {
16342
+ trigger,
16343
+ consecutiveFailures: next.consecutiveFailures,
16344
+ retryInMs: catalogPullRetryDelayMs(next.consecutiveFailures),
16345
+ error: require_dist.errMsg(err)
16346
+ }
16216
16347
  });
16217
16348
  return;
16218
16349
  }
16350
+ if (this.catalogPullFailures.delete(deviceId)) this.logger.info("stream-catalog pull recovered — device is back in the catalog", {
16351
+ tags: { deviceId },
16352
+ meta: { descriptors: descriptors.length }
16353
+ });
16219
16354
  if (descriptors.length === 0) return;
16220
16355
  const keep = new Set(descriptors.map((d) => d.camStreamId));
16221
16356
  await this.publishCatalog(deviceId, descriptors);
@@ -16250,10 +16385,11 @@ var StreamBrokerManager = class StreamBrokerManager {
16250
16385
  await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
16251
16386
  } else this.disabledDevices.delete(d.id);
16252
16387
  const live = cameras.filter((d) => !d.disabled);
16253
- await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id)));
16388
+ await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id, "poll")));
16254
16389
  await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
16255
16390
  const liveIds = new Set(live.map((d) => d.id));
16256
16391
  for (const deviceId of [...this.audioPlaneState.keys()]) if (!liveIds.has(deviceId)) this.audioPlaneState.delete(deviceId);
16392
+ for (const deviceId of [...this.catalogPullFailures.keys()]) if (!liveIds.has(deviceId)) this.catalogPullFailures.delete(deviceId);
16257
16393
  }
16258
16394
  /**
16259
16395
  * ONE device's audio-plane reconcile pass. See `audio-plane-reconcile.ts`
@@ -16395,6 +16531,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16395
16531
  await this.runDeviceCatalogOperation(deviceId, () => this.retractDeviceNow(deviceId, opts));
16396
16532
  }
16397
16533
  async retractDeviceNow(deviceId, opts) {
16534
+ this.catalogPullFailures.delete(deviceId);
16398
16535
  const existing = this.cameraStreams.get(deviceId);
16399
16536
  if (existing) for (const camStreamId of Array.from(existing.keys())) await this.retractCameraStream({
16400
16537
  deviceId,
@@ -1,10 +1,10 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { $ as loadContributionCapability, $t as union, At as CAM_PROFILE_ORDER, Bt as parseProfileBrokerId, C as RATE_CONTROL_RELAXED, Ct as errMsg, D as RingBuffer, Dt as invocationFromEncodeProfile, Et as buildFfmpegArgs$1, F as createHwAccelCache, Ft as isEvent, G as egressTranscodeSharingKey, Gt as boolean, I as createLogChannelsProvider, It as makeProfileBrokerId, Jt as literal, K as egressTransportFromRequest, Kt as discriminatedUnion, Lt as makeSourceBrokerId, Mt as DeviceType, N as batteryCapability, Nt as createEvent, Ot as isSoftwareDecode, P as cameraStreamsCapability, Qt as string, R as declareLogChannel, Rt as nodePin, St as webrtcSessionCapability, Ut as _enum, V as deriveBatteryPresence, Wt as array, Xt as object, Yt as number, Zt as record, bt as streamBrokerCapability, en as EventCategory, et as logChannelsCapability, g as EncodeProfileSchema, ht as resolveEgressDecodeHwAccel, i as BatteryStatusSchema, jt as DeviceFeature, k as addonWidgetsSourceCapability, kt as BaseAddon, nt as maskUrlCredentials, w as RATE_CONTROL_TIGHT, wt as AUDIO_PRESETS } from "../dist-g0Or5_pR.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-Bo1g8hLO.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";
4
4
  import { t as parseRestreamPath } from "../restream-intent-B4BXZra7.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
6
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DMolfhsP.mjs";
7
- import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-C-a2pghM.mjs";
7
+ import { i as require_mp4box_all, n as demuxSegmentPreferJs, r as runFfmpegOnce, t as demuxSegmentFullJs } from "../segment-demux-js-Ck9ly4bX.mjs";
8
8
  import { createRequire } from "node:module";
9
9
  import * as crypto$1 from "node:crypto";
10
10
  import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -2242,6 +2242,20 @@ function appendAudioMediaBlock(videoSdp, audioBlock) {
2242
2242
  return `${videoSdp.replace(/[\r\n]+$/, "")}\r\n${audioBlock}\r\n`;
2243
2243
  }
2244
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
2245
2259
  //#region src/stream-broker/rtsp/rtsp-types.ts
2246
2260
  /** RTSP method constants. */
2247
2261
  var RTSP_METHODS = [
@@ -2868,8 +2882,6 @@ var RtspRestreamer = class {
2868
2882
  if (ready.length === 0) return;
2869
2883
  const preRollPlans = /* @__PURE__ */ new Map();
2870
2884
  const bound = this.currentPrimeBound();
2871
- const overSpan = spanMs > bound.maxSpanMs;
2872
- const overPackets = primePackets.length > bound.maxPackets;
2873
2885
  /**
2874
2886
  * Resolve (and memoize) the plan for one intent.
2875
2887
  *
@@ -2935,14 +2947,18 @@ var RtspRestreamer = class {
2935
2947
  continue;
2936
2948
  }
2937
2949
  }
2938
- if (session.isMuted() && !detectionIntent) {
2939
- if (!fromRing || spanMs > LIVE_EDGE_MAX_SPAN_MS) {
2940
- this.dropsWithheldMutedLiveEdge++;
2941
- withheld++;
2942
- continue;
2943
- }
2944
- } else if (overSpan || overPackets) {
2945
- 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++;
2946
2962
  else this.dropsWithheldPackets++;
2947
2963
  withheld++;
2948
2964
  continue;
@@ -4128,6 +4144,90 @@ function buildAudioCodecCapApi(api) {
4128
4144
  flushEncode: (input) => router.flushEncode.mutate(input)
4129
4145
  };
4130
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
+ }
4131
4231
  /**
4132
4232
  * Minimum gap between two re-dials of the same device. A re-dial costs a
4133
4233
  * ~2 s video interruption on every source of the camera; at the 30 s
@@ -14642,6 +14742,13 @@ var StreamBrokerManager = class StreamBrokerManager {
14642
14742
  deviceOverridePersister = null;
14643
14743
  profileMapPersister = null;
14644
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();
14645
14752
  eventBus = null;
14646
14753
  /**
14647
14754
  * Per-broker stream health flag set by the watchdog. `undefined`
@@ -16193,26 +16300,54 @@ var StreamBrokerManager = class StreamBrokerManager {
16193
16300
  /**
16194
16301
  * Pull one camera's `stream-catalog` and reconcile its cam-streams: upsert
16195
16302
  * every descriptor, retract any cam-stream no longer in the catalog. A pull
16196
- * that throws (provider mid-restart, no `stream-catalog` cap) is skipped
16197
- * the poll retries. An EMPTY catalog is also skipped rather than tearing
16198
- * down (a provider probe can transiently return none); genuine removal flows
16199
- * 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.
16200
16313
  */
16201
- async reconcileDeviceCatalog(deviceId) {
16202
- await this.runDeviceCatalogOperation(deviceId, () => this.reconcileDeviceCatalogNow(deviceId));
16314
+ async reconcileDeviceCatalog(deviceId, trigger = "event") {
16315
+ await this.runDeviceCatalogOperation(deviceId, () => this.reconcileDeviceCatalogNow(deviceId, trigger));
16203
16316
  }
16204
- async reconcileDeviceCatalogNow(deviceId) {
16317
+ async reconcileDeviceCatalogNow(deviceId, trigger) {
16205
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
+ }
16206
16330
  let descriptors;
16207
16331
  try {
16208
16332
  descriptors = await this.withCatalogTimeout(this.api.streamCatalog.getCatalog.query({ deviceId }), deviceId) ?? [];
16209
16333
  } catch (err) {
16210
- 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", {
16211
16337
  tags: { deviceId },
16212
- meta: { error: errMsg(err) }
16338
+ meta: {
16339
+ trigger,
16340
+ consecutiveFailures: next.consecutiveFailures,
16341
+ retryInMs: catalogPullRetryDelayMs(next.consecutiveFailures),
16342
+ error: errMsg(err)
16343
+ }
16213
16344
  });
16214
16345
  return;
16215
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
+ });
16216
16351
  if (descriptors.length === 0) return;
16217
16352
  const keep = new Set(descriptors.map((d) => d.camStreamId));
16218
16353
  await this.publishCatalog(deviceId, descriptors);
@@ -16247,10 +16382,11 @@ var StreamBrokerManager = class StreamBrokerManager {
16247
16382
  await this.releaseDisabledDeviceBrokers(d.id, firstSeen);
16248
16383
  } else this.disabledDevices.delete(d.id);
16249
16384
  const live = cameras.filter((d) => !d.disabled);
16250
- await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id)));
16385
+ await Promise.allSettled(live.map((d) => this.reconcileDeviceCatalog(d.id, "poll")));
16251
16386
  await Promise.allSettled(live.map((d) => this.reconcileDeviceAudioPlane(d.id)));
16252
16387
  const liveIds = new Set(live.map((d) => d.id));
16253
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);
16254
16390
  }
16255
16391
  /**
16256
16392
  * ONE device's audio-plane reconcile pass. See `audio-plane-reconcile.ts`
@@ -16392,6 +16528,7 @@ var StreamBrokerManager = class StreamBrokerManager {
16392
16528
  await this.runDeviceCatalogOperation(deviceId, () => this.retractDeviceNow(deviceId, opts));
16393
16529
  }
16394
16530
  async retractDeviceNow(deviceId, opts) {
16531
+ this.catalogPullFailures.delete(deviceId);
16395
16532
  const existing = this.cameraStreams.get(deviceId);
16396
16533
  if (existing) for (const camStreamId of Array.from(existing.keys())) await this.retractCameraStream({
16397
16534
  deviceId,
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-AH6OJ3hJ.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-Cad8960O.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-4EMKvxCp.js");
1
+ const require_dist = require("./dist-BuIP0499.js");
2
2
  //#region src/session-decode/native-lease-config.ts
3
3
  /**
4
4
  * Resolution of the native-frame hold / tile knobs for one decode worker.
@@ -253,6 +253,7 @@ function isWorkerReply(value) {
253
253
  case "ended": return true;
254
254
  case "error": return typeof value.message === "string" && (value.frameId === void 0 || isFiniteNumber(value.frameId));
255
255
  case "log": return isWorkerLogLevel(value.level) && typeof value.message === "string";
256
+ case "dialTiming": return isFiniteNumber(value.hwContextMs) && isFiniteNumber(value.demuxerOpenMs) && isFiniteNumber(value.decoderCreateMs) && isFiniteNumber(value.firstDecodeMs) && typeof value.streamInfoProbed === "boolean";
256
257
  default: return false;
257
258
  }
258
259
  }
@@ -1,4 +1,4 @@
1
- import { b as NativeLeaseSettingsSchema, p as DEFAULT_NATIVE_LEASE_SETTINGS, y as NativeLeaseAdmissionSchema } from "./dist-g0Or5_pR.mjs";
1
+ import { b as NativeLeaseSettingsSchema, p as DEFAULT_NATIVE_LEASE_SETTINGS, y as NativeLeaseAdmissionSchema } from "./dist-CmEYRK7G.mjs";
2
2
  //#region src/session-decode/native-lease-config.ts
3
3
  /**
4
4
  * Resolution of the native-frame hold / tile knobs for one decode worker.
@@ -253,6 +253,7 @@ function isWorkerReply(value) {
253
253
  case "ended": return true;
254
254
  case "error": return typeof value.message === "string" && (value.frameId === void 0 || isFiniteNumber(value.frameId));
255
255
  case "log": return isWorkerLogLevel(value.level) && typeof value.message === "string";
256
+ case "dialTiming": return isFiniteNumber(value.hwContextMs) && isFiniteNumber(value.demuxerOpenMs) && isFiniteNumber(value.decoderCreateMs) && isFiniteNumber(value.firstDecodeMs) && typeof value.streamInfoProbed === "boolean";
256
257
  default: return false;
257
258
  }
258
259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.137",
3
+ "version": "1.2.138",
4
4
  "description": "Pipeline bundle — runner, detection, motion, audio + stream broker. Multi-entry npm package shipping pipeline addons under a single bundle.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -109,7 +109,8 @@
109
109
  "execution": {
110
110
  "placement": "any-node",
111
111
  "heapProfile": "heavy",
112
- "group": "detection"
112
+ "group": "detection",
113
+ "rssBudgetMb": 1536
113
114
  },
114
115
  "protected": true,
115
116
  "icon": "assets/icon.svg",
@@ -152,7 +153,8 @@
152
153
  "entry": "./dist/audio-analyzer/index.js",
153
154
  "execution": {
154
155
  "placement": "any-node",
155
- "heapProfile": "heavy"
156
+ "heapProfile": "heavy",
157
+ "rssBudgetMb": 512
156
158
  },
157
159
  "capabilities": [
158
160
  {