@camstack/addon-pipeline 1.2.27 → 1.2.29

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.
@@ -4,7 +4,7 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
6
  const require_dist = require("../dist-DUHlZcWd.js");
7
- const require_step_definitions = require("../step-definitions-Bo1hJQQw.js");
7
+ const require_step_definitions = require("../step-definitions-Drb9cWbB.js");
8
8
  const require_node_topology_platform = require("../node-topology-platform-CFZ7F4xW.js");
9
9
  const require_model_download_service_Cp9f4dk6 = require("../model-download-service-Cp9f4dk6-fsdDExML.js");
10
10
  let node_child_process = require("node:child_process");
@@ -2704,19 +2704,25 @@ async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth
2704
2704
  * DROPS detections outright, so it sits between that and a hard cut).
2705
2705
  */
2706
2706
  var FULL_FRAME_MAX_CONFIDENCE = .7;
2707
+ /** A cut only makes sense in (0, 1); anything else is treated as OFF rather
2708
+ * than dropping every detection on a typo. */
2709
+ function isUsableHardAreaCut(ratio) {
2710
+ return Number.isFinite(ratio) && ratio > 0 && ratio < 1;
2711
+ }
2707
2712
  /**
2708
2713
  * True when `bbox` is a near-full-frame LOW-confidence phantom that should be
2709
2714
  * DROPPED from the frame's detections (overlay / tracker / events / motion-wake).
2710
2715
  *
2711
2716
  * Rule (drop ⇔ returns true):
2712
- * (areaRatio ≥ {@link FULL_FRAME_AREA_RATIO}
2713
- * OR width ≥ {@link FULL_FRAME_DIMENSION_RATIO} × frameWidth
2714
- * OR height ≥ {@link FULL_FRAME_DIMENSION_RATIO} × frameHeight)
2715
- * AND score < `maxConfidence`
2717
+ * areaRatio ≥ `hardAreaRatio` ← score-independent, OFF by default
2718
+ * OR ( (areaRatio ≥ {@link FULL_FRAME_AREA_RATIO}
2719
+ * OR width ≥ {@link FULL_FRAME_DIMENSION_RATIO} × frameWidth
2720
+ * OR height {@link FULL_FRAME_DIMENSION_RATIO} × frameHeight)
2721
+ * AND score < `maxConfidence` )
2716
2722
  *
2717
- * A high-confidence detection (`score ≥ maxConfidence`) is trusted as a real
2718
- * subject and always survives. Degenerate frame dimensions (≤0) carry no info
2719
- * to reject on → not a phantom.
2723
+ * Below the hard cut a high-confidence detection (`score ≥ maxConfidence`) is
2724
+ * trusted as a real subject and survives. Degenerate frame dimensions (≤0)
2725
+ * carry no info to reject on → not a phantom.
2720
2726
  *
2721
2727
  * @param bbox absolute-pixel `[x1, y1, x2, y2]` box in source-frame coords.
2722
2728
  * @param frameWidth source frame width in px.
@@ -2725,10 +2731,26 @@ var FULL_FRAME_MAX_CONFIDENCE = .7;
2725
2731
  * @param maxConfidence confidence bar below which a full-frame box is a phantom
2726
2732
  * (defaults to {@link FULL_FRAME_MAX_CONFIDENCE}; overridable by the caller so
2727
2733
  * the bar can be threaded from detection settings without new cap methods).
2734
+ * @param hardAreaRatio area fraction at/above which the box is dropped NO
2735
+ * MATTER the score. Defaults to {@link HARD_AREA_CUT_DISABLED} (off) — it is
2736
+ * armed per camera, because a doorbell close-up legitimately fills the frame
2737
+ * while an overhead courtyard box never does. Threaded from step settings as
2738
+ * `fullFrameGuardHardAreaRatio`.
2728
2739
  */
2729
- function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxConfidence = FULL_FRAME_MAX_CONFIDENCE) {
2740
+ /**
2741
+ * True when a box trips the guard's GEOMETRY test (near-full-frame, or
2742
+ * edge-to-edge in one axis) but SURVIVES because its score cleared the
2743
+ * confidence bar.
2744
+ *
2745
+ * These are the ones worth counting: on 2026-07-30 device 615 produced six
2746
+ * such boxes in a day, at 0.710-0.787, and they were only found by an offline
2747
+ * sweep of stored events — nothing in production reported them. Arming the
2748
+ * hard-area cut on a camera should be a decision made from this count, not
2749
+ * from one incident.
2750
+ */
2751
+ function isFullFrameSurvivor(bbox, frameWidth, frameHeight, score, maxConfidence = FULL_FRAME_MAX_CONFIDENCE) {
2730
2752
  if (frameWidth <= 0 || frameHeight <= 0) return false;
2731
- if (score >= maxConfidence) return false;
2753
+ if (score < maxConfidence) return false;
2732
2754
  const [x1, y1, x2, y2] = bbox;
2733
2755
  const w = x2 - x1;
2734
2756
  const h = y2 - y1;
@@ -2737,6 +2759,19 @@ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxCo
2737
2759
  if (h >= frameHeight * .98) return true;
2738
2760
  return false;
2739
2761
  }
2762
+ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxConfidence = FULL_FRAME_MAX_CONFIDENCE, hardAreaRatio = 1) {
2763
+ if (frameWidth <= 0 || frameHeight <= 0) return false;
2764
+ const [x1, y1, x2, y2] = bbox;
2765
+ const w = x2 - x1;
2766
+ const h = y2 - y1;
2767
+ const areaRatio = w * h / (frameWidth * frameHeight);
2768
+ if (isUsableHardAreaCut(hardAreaRatio) && areaRatio >= hardAreaRatio) return true;
2769
+ if (score >= maxConfidence) return false;
2770
+ if (areaRatio >= .9) return true;
2771
+ if (w >= frameWidth * .98) return true;
2772
+ if (h >= frameHeight * .98) return true;
2773
+ return false;
2774
+ }
2740
2775
  //#endregion
2741
2776
  //#region src/detection-pipeline/pipeline/plate-deskew.ts
2742
2777
  /**
@@ -3584,10 +3619,34 @@ var PipelineExecutor = class {
3584
3619
  continue;
3585
3620
  }
3586
3621
  const fullFrameBar = rootStep.settings?.["fullFrameGuardMaxConfidence"];
3587
- if (isFullFramePhantomDetection(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) {
3588
- if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} bbox=${JSON.stringify(mutable.bbox)} (full-frame phantom guard)`);
3622
+ const fullFrameHardArea = rootStep.settings?.["fullFrameGuardHardAreaRatio"];
3623
+ if (isFullFramePhantomDetection(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0, typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0)) {
3624
+ this.opts.logger?.info("full-frame phantom guard: detection dropped", {
3625
+ tags: { deviceId },
3626
+ meta: {
3627
+ macroClass: mutable.macroClass,
3628
+ score: mutable.score,
3629
+ bbox: mutable.bbox,
3630
+ frameWidth: imageWidth,
3631
+ frameHeight: imageHeight,
3632
+ areaRatio: imageWidth > 0 && imageHeight > 0 ? Math.round((mutable.bbox[2] - mutable.bbox[0]) * (mutable.bbox[3] - mutable.bbox[1]) / (imageWidth * imageHeight) * 1e3) / 1e3 : void 0,
3633
+ bar: typeof fullFrameBar === "number" ? fullFrameBar : void 0,
3634
+ hardAreaRatio: typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0
3635
+ }
3636
+ });
3589
3637
  continue;
3590
3638
  }
3639
+ if (isFullFrameSurvivor(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) this.opts.logger?.warn("full-frame box SURVIVED the guard on score", {
3640
+ tags: { deviceId },
3641
+ meta: {
3642
+ macroClass: mutable.macroClass,
3643
+ score: mutable.score,
3644
+ bbox: mutable.bbox,
3645
+ areaRatio: imageWidth > 0 && imageHeight > 0 ? Math.round((mutable.bbox[2] - mutable.bbox[0]) * (mutable.bbox[3] - mutable.bbox[1]) / (imageWidth * imageHeight) * 1e3) / 1e3 : void 0,
3646
+ bar: typeof fullFrameBar === "number" ? fullFrameBar : void 0,
3647
+ hint: "arm fullFrameGuardHardAreaRatio on this camera if these are phantoms"
3648
+ }
3649
+ });
3591
3650
  try {
3592
3651
  await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeCropProvider);
3593
3652
  } catch (err) {
@@ -1,6 +1,6 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
2
  import { B as errMsg, E as evaluateZoneRules, F as runtimeDevices$1, G as createEvent, K as hydrateSchema, R as supportedRuntimes$1, T as enumerateInferenceDevices, V as BaseAddon, X as nodePin, Z as parseJsonUnknown, ct as object, dt as union, et as sleep, ft as EventCategory, h as YAMNET_TO_MACRO, j as pipelineExecutorCapability, nt as array, s as DEVICE_BACKEND_TO_FORMAT, t as APPLE_SA_TO_MACRO, ut as string, w as detectionPipelineCapability, x as defaultDeviceFor$1 } from "../dist-CsKaXmP8.mjs";
3
- import { a as getStepDefinition, i as getStep, n as ALL_STEPS, o as resolveModelForFormat, r as getDefaultModelForFormat, t as ALL_PIPELINE_STEPS } from "../step-definitions-C8A78dBi.mjs";
3
+ import { a as getStepDefinition, i as getStep, n as ALL_STEPS, o as resolveModelForFormat, r as getDefaultModelForFormat, t as ALL_PIPELINE_STEPS } from "../step-definitions-1GIwU6R8.mjs";
4
4
  import { t as pickNodePlatformArch } from "../node-topology-platform-BkR_k6WT.mjs";
5
5
  import { a as ensureModel, o as isModelDownloaded, r as deleteModelFromDisk } from "../model-download-service-Cp9f4dk6-CwUb5v3Y.mjs";
6
6
  import { spawn } from "node:child_process";
@@ -2696,19 +2696,25 @@ async function nativeChildCrop(provider, bbox, imageWidth, imageHeight, maxWidth
2696
2696
  * DROPS detections outright, so it sits between that and a hard cut).
2697
2697
  */
2698
2698
  var FULL_FRAME_MAX_CONFIDENCE = .7;
2699
+ /** A cut only makes sense in (0, 1); anything else is treated as OFF rather
2700
+ * than dropping every detection on a typo. */
2701
+ function isUsableHardAreaCut(ratio) {
2702
+ return Number.isFinite(ratio) && ratio > 0 && ratio < 1;
2703
+ }
2699
2704
  /**
2700
2705
  * True when `bbox` is a near-full-frame LOW-confidence phantom that should be
2701
2706
  * DROPPED from the frame's detections (overlay / tracker / events / motion-wake).
2702
2707
  *
2703
2708
  * Rule (drop ⇔ returns true):
2704
- * (areaRatio ≥ {@link FULL_FRAME_AREA_RATIO}
2705
- * OR width ≥ {@link FULL_FRAME_DIMENSION_RATIO} × frameWidth
2706
- * OR height ≥ {@link FULL_FRAME_DIMENSION_RATIO} × frameHeight)
2707
- * AND score < `maxConfidence`
2709
+ * areaRatio ≥ `hardAreaRatio` ← score-independent, OFF by default
2710
+ * OR ( (areaRatio ≥ {@link FULL_FRAME_AREA_RATIO}
2711
+ * OR width ≥ {@link FULL_FRAME_DIMENSION_RATIO} × frameWidth
2712
+ * OR height {@link FULL_FRAME_DIMENSION_RATIO} × frameHeight)
2713
+ * AND score < `maxConfidence` )
2708
2714
  *
2709
- * A high-confidence detection (`score ≥ maxConfidence`) is trusted as a real
2710
- * subject and always survives. Degenerate frame dimensions (≤0) carry no info
2711
- * to reject on → not a phantom.
2715
+ * Below the hard cut a high-confidence detection (`score ≥ maxConfidence`) is
2716
+ * trusted as a real subject and survives. Degenerate frame dimensions (≤0)
2717
+ * carry no info to reject on → not a phantom.
2712
2718
  *
2713
2719
  * @param bbox absolute-pixel `[x1, y1, x2, y2]` box in source-frame coords.
2714
2720
  * @param frameWidth source frame width in px.
@@ -2717,10 +2723,26 @@ var FULL_FRAME_MAX_CONFIDENCE = .7;
2717
2723
  * @param maxConfidence confidence bar below which a full-frame box is a phantom
2718
2724
  * (defaults to {@link FULL_FRAME_MAX_CONFIDENCE}; overridable by the caller so
2719
2725
  * the bar can be threaded from detection settings without new cap methods).
2726
+ * @param hardAreaRatio area fraction at/above which the box is dropped NO
2727
+ * MATTER the score. Defaults to {@link HARD_AREA_CUT_DISABLED} (off) — it is
2728
+ * armed per camera, because a doorbell close-up legitimately fills the frame
2729
+ * while an overhead courtyard box never does. Threaded from step settings as
2730
+ * `fullFrameGuardHardAreaRatio`.
2720
2731
  */
2721
- function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxConfidence = FULL_FRAME_MAX_CONFIDENCE) {
2732
+ /**
2733
+ * True when a box trips the guard's GEOMETRY test (near-full-frame, or
2734
+ * edge-to-edge in one axis) but SURVIVES because its score cleared the
2735
+ * confidence bar.
2736
+ *
2737
+ * These are the ones worth counting: on 2026-07-30 device 615 produced six
2738
+ * such boxes in a day, at 0.710-0.787, and they were only found by an offline
2739
+ * sweep of stored events — nothing in production reported them. Arming the
2740
+ * hard-area cut on a camera should be a decision made from this count, not
2741
+ * from one incident.
2742
+ */
2743
+ function isFullFrameSurvivor(bbox, frameWidth, frameHeight, score, maxConfidence = FULL_FRAME_MAX_CONFIDENCE) {
2722
2744
  if (frameWidth <= 0 || frameHeight <= 0) return false;
2723
- if (score >= maxConfidence) return false;
2745
+ if (score < maxConfidence) return false;
2724
2746
  const [x1, y1, x2, y2] = bbox;
2725
2747
  const w = x2 - x1;
2726
2748
  const h = y2 - y1;
@@ -2729,6 +2751,19 @@ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxCo
2729
2751
  if (h >= frameHeight * .98) return true;
2730
2752
  return false;
2731
2753
  }
2754
+ function isFullFramePhantomDetection(bbox, frameWidth, frameHeight, score, maxConfidence = FULL_FRAME_MAX_CONFIDENCE, hardAreaRatio = 1) {
2755
+ if (frameWidth <= 0 || frameHeight <= 0) return false;
2756
+ const [x1, y1, x2, y2] = bbox;
2757
+ const w = x2 - x1;
2758
+ const h = y2 - y1;
2759
+ const areaRatio = w * h / (frameWidth * frameHeight);
2760
+ if (isUsableHardAreaCut(hardAreaRatio) && areaRatio >= hardAreaRatio) return true;
2761
+ if (score >= maxConfidence) return false;
2762
+ if (areaRatio >= .9) return true;
2763
+ if (w >= frameWidth * .98) return true;
2764
+ if (h >= frameHeight * .98) return true;
2765
+ return false;
2766
+ }
2732
2767
  //#endregion
2733
2768
  //#region src/detection-pipeline/pipeline/plate-deskew.ts
2734
2769
  /**
@@ -3576,10 +3611,34 @@ var PipelineExecutor = class {
3576
3611
  continue;
3577
3612
  }
3578
3613
  const fullFrameBar = rootStep.settings?.["fullFrameGuardMaxConfidence"];
3579
- if (isFullFramePhantomDetection(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) {
3580
- if (debug) console.log(`[executor] drop det macro=${mutable.macroClass} score=${mutable.score} bbox=${JSON.stringify(mutable.bbox)} (full-frame phantom guard)`);
3614
+ const fullFrameHardArea = rootStep.settings?.["fullFrameGuardHardAreaRatio"];
3615
+ if (isFullFramePhantomDetection(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0, typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0)) {
3616
+ this.opts.logger?.info("full-frame phantom guard: detection dropped", {
3617
+ tags: { deviceId },
3618
+ meta: {
3619
+ macroClass: mutable.macroClass,
3620
+ score: mutable.score,
3621
+ bbox: mutable.bbox,
3622
+ frameWidth: imageWidth,
3623
+ frameHeight: imageHeight,
3624
+ areaRatio: imageWidth > 0 && imageHeight > 0 ? Math.round((mutable.bbox[2] - mutable.bbox[0]) * (mutable.bbox[3] - mutable.bbox[1]) / (imageWidth * imageHeight) * 1e3) / 1e3 : void 0,
3625
+ bar: typeof fullFrameBar === "number" ? fullFrameBar : void 0,
3626
+ hardAreaRatio: typeof fullFrameHardArea === "number" ? fullFrameHardArea : void 0
3627
+ }
3628
+ });
3581
3629
  continue;
3582
3630
  }
3631
+ if (isFullFrameSurvivor(mutable.bbox, imageWidth, imageHeight, mutable.score, typeof fullFrameBar === "number" ? fullFrameBar : void 0)) this.opts.logger?.warn("full-frame box SURVIVED the guard on score", {
3632
+ tags: { deviceId },
3633
+ meta: {
3634
+ macroClass: mutable.macroClass,
3635
+ score: mutable.score,
3636
+ bbox: mutable.bbox,
3637
+ areaRatio: imageWidth > 0 && imageHeight > 0 ? Math.round((mutable.bbox[2] - mutable.bbox[0]) * (mutable.bbox[3] - mutable.bbox[1]) / (imageWidth * imageHeight) * 1e3) / 1e3 : void 0,
3638
+ bar: typeof fullFrameBar === "number" ? fullFrameBar : void 0,
3639
+ hint: "arm fullFrameGuardHardAreaRatio on this camera if these are phantoms"
3640
+ }
3641
+ });
3583
3642
  try {
3584
3643
  await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg, runOpts?.plane, deviceId, nativeCropProvider);
3585
3644
  } catch (err) {
@@ -7,7 +7,7 @@ const require_dist = require("../dist-DUHlZcWd.js");
7
7
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
8
8
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
9
9
  const require_worker_protocol = require("../worker-protocol-DextwlTX.js");
10
- const require_step_definitions = require("../step-definitions-Bo1hJQQw.js");
10
+ const require_step_definitions = require("../step-definitions-Drb9cWbB.js");
11
11
  let node_child_process = require("node:child_process");
12
12
  let node_url = require("node:url");
13
13
  let sharp = require("sharp");
@@ -658,6 +658,9 @@ function toFrameInput$1(frame) {
658
658
  * without spinning up Moleculer or the addon framework.
659
659
  */
660
660
  var PipelineRunner = class {
661
+ /** Abandoned inference calls that have not yet settled. A BURST reads as
662
+ * one rising number instead of N unrelated lines. */
663
+ inflightTimeouts = 0;
661
664
  config;
662
665
  cameras = /* @__PURE__ */ new Map();
663
666
  semaphore;
@@ -1144,16 +1147,33 @@ var PipelineRunner = class {
1144
1147
  timer.unref?.();
1145
1148
  });
1146
1149
  try {
1147
- const outcome = await Promise.race([this.config.processFrame(deviceId, frameInput, executorHandle), expiry]);
1150
+ const inflight = this.config.processFrame(deviceId, frameInput, executorHandle);
1151
+ const outcome = await Promise.race([inflight, expiry]);
1148
1152
  if (outcome === TIMED_OUT) {
1149
1153
  release();
1154
+ this.inflightTimeouts += 1;
1150
1155
  this.logger?.warn("inference timed out — permit released, result discarded", {
1151
1156
  tags: { deviceId },
1152
1157
  meta: {
1153
1158
  timeoutMs,
1154
- availablePermits: this.semaphore.available
1159
+ availablePermits: this.semaphore.available,
1160
+ outstandingTimeouts: this.inflightTimeouts
1155
1161
  }
1156
1162
  });
1163
+ const deadlineAt = Date.now();
1164
+ const noteSettled = (fate) => {
1165
+ this.inflightTimeouts = Math.max(0, this.inflightTimeouts - 1);
1166
+ this.logger?.info("inference: abandoned call settled after the deadline", {
1167
+ tags: { deviceId },
1168
+ meta: {
1169
+ lateByMs: Date.now() - deadlineAt,
1170
+ totalMs: timeoutMs + (Date.now() - deadlineAt),
1171
+ fate,
1172
+ outstandingTimeouts: this.inflightTimeouts
1173
+ }
1174
+ });
1175
+ };
1176
+ inflight.then(() => noteSettled("resolved"), () => noteSettled("rejected"));
1157
1177
  }
1158
1178
  return outcome;
1159
1179
  } finally {
@@ -1336,7 +1356,24 @@ function forkDecodeWorker(_source) {
1336
1356
  //#endregion
1337
1357
  //#region src/session-decode/guarded-session-decode.ts
1338
1358
  /**
1339
- * Guards the in-flight restream-acquire against a detach/unsubscribe race.
1359
+ * 2s 5s 15s 30s, then 30s forever.
1360
+ *
1361
+ * Indefinite is deliberate. The failure this recovers from is "the broker has
1362
+ * no profile bound yet" or "the broker call failed right now" — both of which
1363
+ * clear on their own, often minutes later (a source re-dialing, a runner
1364
+ * restarting). A bounded retry would turn a slow recovery back into the
1365
+ * permanent outage this exists to prevent, and teardown already stops it: a
1366
+ * detach aborts the loop, so nothing retries for a camera nobody wants.
1367
+ */
1368
+ var DEFAULT_ACQUIRE_RETRY_DELAYS_MS = [
1369
+ 2e3,
1370
+ 5e3,
1371
+ 15e3,
1372
+ 3e4
1373
+ ];
1374
+ /**
1375
+ * Guards the in-flight restream-acquire against a detach/unsubscribe race,
1376
+ * and retries an acquire that fails.
1340
1377
  *
1341
1378
  * `acquireSessionDecodeRestream` + forking the decode worker both take real
1342
1379
  * time (a broker round-trip, then a process fork). If the caller's teardown
@@ -1348,29 +1385,71 @@ function forkDecodeWorker(_source) {
1348
1385
  *
1349
1386
  * This helper returns the teardown SYNCHRONOUSLY. The acquire + pump-start
1350
1387
  * run in the background; an `aborted` flag recorded by the (possibly
1351
- * already-called) teardown is checked right after the acquire resolves —
1352
- * if set, the restream is released immediately and the pump is NEVER
1388
+ * already-called) teardown is checked right after each acquire resolves —
1389
+ * if set, any restream is released immediately and the pump is NEVER
1353
1390
  * started (no orphan worker). Otherwise the pump starts and the teardown,
1354
1391
  * once called, stops the pump then releases the restream.
1392
+ *
1393
+ * **A failed acquire is retried, not swallowed.** The teardown handed back
1394
+ * here is stored by the caller (as `attachment.detectionUnsubscribe`) the
1395
+ * instant this returns, so a `return` on failure left that handle permanently
1396
+ * non-null with no pump behind it: `onEnded` could never fire because nothing
1397
+ * had ever started, the camera's phase stayed `active`, and not one frame
1398
+ * arrived again. The motion path was hardened against this shape by a frame
1399
+ * freshness window (`MOTION_FRESHNESS_WINDOW_MS`); this is the detection
1400
+ * path's half.
1355
1401
  */
1356
1402
  function startGuardedSessionDecode(deps) {
1357
1403
  let aborted = false;
1358
1404
  let stopPump = null;
1359
1405
  let release = null;
1406
+ let retryTimer = null;
1407
+ const delays = deps.acquireRetryDelaysMs && deps.acquireRetryDelaysMs.length > 0 ? deps.acquireRetryDelaysMs : DEFAULT_ACQUIRE_RETRY_DELAYS_MS;
1408
+ /** Resolves after the backoff for `attempt`, or immediately once aborted. */
1409
+ const waitBackoff = (attempt) => new Promise((resolve) => {
1410
+ const ms = delays[Math.min(attempt, delays.length - 1)] ?? delays[delays.length - 1] ?? 2e3;
1411
+ retryTimer = setTimeout(() => {
1412
+ retryTimer = null;
1413
+ resolve();
1414
+ }, ms);
1415
+ retryTimer.unref?.();
1416
+ });
1360
1417
  (async () => {
1361
- const acquired = await deps.acquire();
1362
- if (!acquired) return;
1363
- if (aborted) {
1364
- acquired.release();
1418
+ for (let attempt = 0; !aborted; attempt++) {
1419
+ let acquired = null;
1420
+ try {
1421
+ acquired = await deps.acquire();
1422
+ } catch (err) {
1423
+ (attempt === 0 ? deps.logger.warn : deps.logger.debug).call(deps.logger, "session-decode: acquire failed — retrying", { meta: {
1424
+ attempt: attempt + 1,
1425
+ error: require_dist.errMsg(err)
1426
+ } });
1427
+ await waitBackoff(attempt);
1428
+ continue;
1429
+ }
1430
+ if (aborted) {
1431
+ if (acquired) acquired.release();
1432
+ return;
1433
+ }
1434
+ if (!acquired) {
1435
+ (attempt === 0 ? deps.logger.warn : deps.logger.debug).call(deps.logger, "session-decode: no broker profile bound yet — retrying", { meta: { attempt: attempt + 1 } });
1436
+ await waitBackoff(attempt);
1437
+ continue;
1438
+ }
1439
+ if (attempt > 0) deps.logger.info("session-decode: acquire succeeded after retry", { meta: { attempts: attempt + 1 } });
1440
+ release = acquired.release;
1441
+ stopPump = deps.startPump(acquired.source);
1365
1442
  return;
1366
1443
  }
1367
- release = acquired.release;
1368
- stopPump = deps.startPump(acquired.source);
1369
1444
  })().catch((err) => {
1370
1445
  deps.logger.warn("session-decode: guarded start failed", { meta: { error: require_dist.errMsg(err) } });
1371
1446
  });
1372
1447
  return () => {
1373
1448
  aborted = true;
1449
+ if (retryTimer !== null) {
1450
+ clearTimeout(retryTimer);
1451
+ retryTimer = null;
1452
+ }
1374
1453
  stopPump?.();
1375
1454
  if (release) release();
1376
1455
  };
@@ -2,7 +2,7 @@ import { B as errMsg, G as createEvent, M as pipelineRunnerCapability, S as defi
2
2
  import { n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-BeHi78PZ.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { t as isWorkerReply } from "../worker-protocol-D7RzZIla.mjs";
5
- import { a as getStepDefinition } from "../step-definitions-C8A78dBi.mjs";
5
+ import { a as getStepDefinition } from "../step-definitions-1GIwU6R8.mjs";
6
6
  import { fork } from "node:child_process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import sharp from "sharp";
@@ -652,6 +652,9 @@ function toFrameInput$1(frame) {
652
652
  * without spinning up Moleculer or the addon framework.
653
653
  */
654
654
  var PipelineRunner = class {
655
+ /** Abandoned inference calls that have not yet settled. A BURST reads as
656
+ * one rising number instead of N unrelated lines. */
657
+ inflightTimeouts = 0;
655
658
  config;
656
659
  cameras = /* @__PURE__ */ new Map();
657
660
  semaphore;
@@ -1138,16 +1141,33 @@ var PipelineRunner = class {
1138
1141
  timer.unref?.();
1139
1142
  });
1140
1143
  try {
1141
- const outcome = await Promise.race([this.config.processFrame(deviceId, frameInput, executorHandle), expiry]);
1144
+ const inflight = this.config.processFrame(deviceId, frameInput, executorHandle);
1145
+ const outcome = await Promise.race([inflight, expiry]);
1142
1146
  if (outcome === TIMED_OUT) {
1143
1147
  release();
1148
+ this.inflightTimeouts += 1;
1144
1149
  this.logger?.warn("inference timed out — permit released, result discarded", {
1145
1150
  tags: { deviceId },
1146
1151
  meta: {
1147
1152
  timeoutMs,
1148
- availablePermits: this.semaphore.available
1153
+ availablePermits: this.semaphore.available,
1154
+ outstandingTimeouts: this.inflightTimeouts
1149
1155
  }
1150
1156
  });
1157
+ const deadlineAt = Date.now();
1158
+ const noteSettled = (fate) => {
1159
+ this.inflightTimeouts = Math.max(0, this.inflightTimeouts - 1);
1160
+ this.logger?.info("inference: abandoned call settled after the deadline", {
1161
+ tags: { deviceId },
1162
+ meta: {
1163
+ lateByMs: Date.now() - deadlineAt,
1164
+ totalMs: timeoutMs + (Date.now() - deadlineAt),
1165
+ fate,
1166
+ outstandingTimeouts: this.inflightTimeouts
1167
+ }
1168
+ });
1169
+ };
1170
+ inflight.then(() => noteSettled("resolved"), () => noteSettled("rejected"));
1151
1171
  }
1152
1172
  return outcome;
1153
1173
  } finally {
@@ -1330,7 +1350,24 @@ function forkDecodeWorker(_source) {
1330
1350
  //#endregion
1331
1351
  //#region src/session-decode/guarded-session-decode.ts
1332
1352
  /**
1333
- * Guards the in-flight restream-acquire against a detach/unsubscribe race.
1353
+ * 2s 5s 15s 30s, then 30s forever.
1354
+ *
1355
+ * Indefinite is deliberate. The failure this recovers from is "the broker has
1356
+ * no profile bound yet" or "the broker call failed right now" — both of which
1357
+ * clear on their own, often minutes later (a source re-dialing, a runner
1358
+ * restarting). A bounded retry would turn a slow recovery back into the
1359
+ * permanent outage this exists to prevent, and teardown already stops it: a
1360
+ * detach aborts the loop, so nothing retries for a camera nobody wants.
1361
+ */
1362
+ var DEFAULT_ACQUIRE_RETRY_DELAYS_MS = [
1363
+ 2e3,
1364
+ 5e3,
1365
+ 15e3,
1366
+ 3e4
1367
+ ];
1368
+ /**
1369
+ * Guards the in-flight restream-acquire against a detach/unsubscribe race,
1370
+ * and retries an acquire that fails.
1334
1371
  *
1335
1372
  * `acquireSessionDecodeRestream` + forking the decode worker both take real
1336
1373
  * time (a broker round-trip, then a process fork). If the caller's teardown
@@ -1342,29 +1379,71 @@ function forkDecodeWorker(_source) {
1342
1379
  *
1343
1380
  * This helper returns the teardown SYNCHRONOUSLY. The acquire + pump-start
1344
1381
  * run in the background; an `aborted` flag recorded by the (possibly
1345
- * already-called) teardown is checked right after the acquire resolves —
1346
- * if set, the restream is released immediately and the pump is NEVER
1382
+ * already-called) teardown is checked right after each acquire resolves —
1383
+ * if set, any restream is released immediately and the pump is NEVER
1347
1384
  * started (no orphan worker). Otherwise the pump starts and the teardown,
1348
1385
  * once called, stops the pump then releases the restream.
1386
+ *
1387
+ * **A failed acquire is retried, not swallowed.** The teardown handed back
1388
+ * here is stored by the caller (as `attachment.detectionUnsubscribe`) the
1389
+ * instant this returns, so a `return` on failure left that handle permanently
1390
+ * non-null with no pump behind it: `onEnded` could never fire because nothing
1391
+ * had ever started, the camera's phase stayed `active`, and not one frame
1392
+ * arrived again. The motion path was hardened against this shape by a frame
1393
+ * freshness window (`MOTION_FRESHNESS_WINDOW_MS`); this is the detection
1394
+ * path's half.
1349
1395
  */
1350
1396
  function startGuardedSessionDecode(deps) {
1351
1397
  let aborted = false;
1352
1398
  let stopPump = null;
1353
1399
  let release = null;
1400
+ let retryTimer = null;
1401
+ const delays = deps.acquireRetryDelaysMs && deps.acquireRetryDelaysMs.length > 0 ? deps.acquireRetryDelaysMs : DEFAULT_ACQUIRE_RETRY_DELAYS_MS;
1402
+ /** Resolves after the backoff for `attempt`, or immediately once aborted. */
1403
+ const waitBackoff = (attempt) => new Promise((resolve) => {
1404
+ const ms = delays[Math.min(attempt, delays.length - 1)] ?? delays[delays.length - 1] ?? 2e3;
1405
+ retryTimer = setTimeout(() => {
1406
+ retryTimer = null;
1407
+ resolve();
1408
+ }, ms);
1409
+ retryTimer.unref?.();
1410
+ });
1354
1411
  (async () => {
1355
- const acquired = await deps.acquire();
1356
- if (!acquired) return;
1357
- if (aborted) {
1358
- acquired.release();
1412
+ for (let attempt = 0; !aborted; attempt++) {
1413
+ let acquired = null;
1414
+ try {
1415
+ acquired = await deps.acquire();
1416
+ } catch (err) {
1417
+ (attempt === 0 ? deps.logger.warn : deps.logger.debug).call(deps.logger, "session-decode: acquire failed — retrying", { meta: {
1418
+ attempt: attempt + 1,
1419
+ error: errMsg(err)
1420
+ } });
1421
+ await waitBackoff(attempt);
1422
+ continue;
1423
+ }
1424
+ if (aborted) {
1425
+ if (acquired) acquired.release();
1426
+ return;
1427
+ }
1428
+ if (!acquired) {
1429
+ (attempt === 0 ? deps.logger.warn : deps.logger.debug).call(deps.logger, "session-decode: no broker profile bound yet — retrying", { meta: { attempt: attempt + 1 } });
1430
+ await waitBackoff(attempt);
1431
+ continue;
1432
+ }
1433
+ if (attempt > 0) deps.logger.info("session-decode: acquire succeeded after retry", { meta: { attempts: attempt + 1 } });
1434
+ release = acquired.release;
1435
+ stopPump = deps.startPump(acquired.source);
1359
1436
  return;
1360
1437
  }
1361
- release = acquired.release;
1362
- stopPump = deps.startPump(acquired.source);
1363
1438
  })().catch((err) => {
1364
1439
  deps.logger.warn("session-decode: guarded start failed", { meta: { error: errMsg(err) } });
1365
1440
  });
1366
1441
  return () => {
1367
1442
  aborted = true;
1443
+ if (retryTimer !== null) {
1444
+ clearTimeout(retryTimer);
1445
+ retryTimer = null;
1446
+ }
1368
1447
  stopPump?.();
1369
1448
  if (release) release();
1370
1449
  };
@@ -1296,7 +1296,18 @@ var ObjectDetectionStep = class {
1296
1296
  min: 0,
1297
1297
  max: 1,
1298
1298
  step: .05,
1299
- default: .5,
1299
+ default: .65,
1300
+ showValue: true
1301
+ },
1302
+ {
1303
+ type: "slider",
1304
+ key: "fullFrameGuardHardAreaRatio",
1305
+ label: "Drop full-frame boxes above",
1306
+ description: "Fraction of the frame at or above which a detection is dropped WHATEVER its score. 1 = off. Use on cameras where a subject can never fill the frame; leave off for doorbells and close-up lenses.",
1307
+ min: .5,
1308
+ max: 1,
1309
+ step: .01,
1310
+ default: 1,
1300
1311
  showValue: true
1301
1312
  },
1302
1313
  {
@@ -1296,7 +1296,18 @@ var ObjectDetectionStep = class {
1296
1296
  min: 0,
1297
1297
  max: 1,
1298
1298
  step: .05,
1299
- default: .5,
1299
+ default: .65,
1300
+ showValue: true
1301
+ },
1302
+ {
1303
+ type: "slider",
1304
+ key: "fullFrameGuardHardAreaRatio",
1305
+ label: "Drop full-frame boxes above",
1306
+ description: "Fraction of the frame at or above which a detection is dropped WHATEVER its score. 1 = off. Use on cameras where a subject can never fill the frame; leave off for doorbells and close-up lenses.",
1307
+ min: .5,
1308
+ max: 1,
1309
+ step: .01,
1310
+ default: 1,
1300
1311
  showValue: true
1301
1312
  },
1302
1313
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline",
3
- "version": "1.2.27",
3
+ "version": "1.2.29",
4
4
  "description": "CamStack 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",