@camstack/addon-pipeline 1.2.42 → 1.2.44

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 (39) hide show
  1. package/dist/audio-analyzer/index.js +4 -4
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +164 -88
  4. package/dist/detection-pipeline/index.mjs +158 -82
  5. package/dist/{dist-BdDd3ql_.js → dist-BuFE5rOK.js} +1608 -36
  6. package/dist/{dist-COK_1Ot8.mjs → dist-DOu93i_g.mjs} +1525 -37
  7. package/dist/{event-loop-stall-monitor-CWOJtTNc.mjs → event-loop-stall-monitor-CShgQE6l.mjs} +40 -2
  8. package/dist/{event-loop-stall-monitor-DNFRgNef.js → event-loop-stall-monitor-L08yG6bB.js} +40 -2
  9. package/dist/{model-download-service-Cp9f4dk6-fsdDExML.js → model-download-service-D8B-4ktF-7-YxE9Wx.js} +2 -2
  10. package/dist/{model-download-service-Cp9f4dk6-CwUb5v3Y.mjs → model-download-service-D8B-4ktF-BB7oZL3y.mjs} +2 -2
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +496 -67
  14. package/dist/pipeline-runner/index.mjs +496 -67
  15. package/dist/recorder/index.js +194 -11
  16. package/dist/recorder/index.mjs +191 -8
  17. package/dist/session-decode/decode-worker-child.js +197 -24
  18. package/dist/session-decode/decode-worker-child.mjs +197 -24
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C8ovZN0g.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DiXoZnou.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BRc-m6W-.mjs +26 -0
  22. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DAjAxXHm.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B_hakWAb.mjs} +1 -1
  23. package/dist/stream-broker/{hostInit-mEtjQWcr.mjs → hostInit-khRXMnjl.mjs} +2 -2
  24. package/dist/stream-broker/index.js +17331 -14101
  25. package/dist/stream-broker/index.mjs +17330 -14100
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/worker-protocol-BOXlUhWO.mjs +244 -0
  28. package/dist/worker-protocol-VURr0nUh.js +279 -0
  29. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-C0TIfr0G.js → MaskShapeCanvas-DI4BY7W2-DHZuYYjH.js} +1 -1
  30. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-x0SkfHwv.js → MotionZonesSettings-NcxxQN8r-jFDOzPCD.js} +1 -1
  31. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BoaAB4Ta.js → PrivacyMaskSettings-APgPLF7p-BPBXgIX8.js} +1 -1
  32. package/embed-dist/assets/{index-BgRdCCVy.js → index-4CtQAybX.js} +12 -12
  33. package/embed-dist/assets/index-DC63og2c.css +2 -0
  34. package/embed-dist/index.html +2 -2
  35. package/package.json +1 -1
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-SU_6F3Y7.mjs +0 -26
  37. package/dist/worker-protocol-D7RzZIla.mjs +0 -77
  38. package/dist/worker-protocol-DextwlTX.js +0 -94
  39. package/embed-dist/assets/index-CGdwTcwE.css +0 -2
@@ -3,9 +3,9 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-BdDd3ql_.js");
7
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DNFRgNef.js");
8
- const require_worker_protocol = require("../worker-protocol-DextwlTX.js");
6
+ const require_dist = require("../dist-BuFE5rOK.js");
7
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-L08yG6bB.js");
8
+ const require_worker_protocol = require("../worker-protocol-VURr0nUh.js");
9
9
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
10
10
  const require_lazy_sharp = require("../lazy-sharp-CcYuXtsa.js");
11
11
  const require_remote_restream = require("../remote-restream-CO36Sr30.js");
@@ -83,9 +83,28 @@ function wrapChild(child) {
83
83
  * Fork one decode-worker child. `source` is part of the coordinator's fork
84
84
  * signature (it forwards the same source in the subsequent `start` message);
85
85
  * the child entry itself is source-independent, so it is not consumed here.
86
+ *
87
+ * `leaseOverride` carries the operator's cluster-wide native-lease settings
88
+ * (`pipeline-orchestrator` globals, read by `native-lease-settings-source.ts`)
89
+ * into the child as environment variables — the child constructs its lease
90
+ * store and demand gate at module load, before the first `start` message, so
91
+ * the values have to be present at spawn. Only the knobs the operator actually
92
+ * SET are emitted, which is what keeps the pre-settings env override
93
+ * (`CAMSTACK_SESSION_NATIVE_LEASE_*`) working for the untouched ones; see
94
+ * `native-lease-config.ts` for the full precedence.
95
+ *
96
+ * The inherited environment is SPREAD, never replaced: this child resolves
97
+ * node-av through `CAMSTACK_FRAMEWORK_DIR` / `CAMSTACK_BUNDLED_ADDONS_DIR` on
98
+ * agents, so a replaced env would silently break decoding off the hub.
86
99
  */
87
- function forkDecodeWorker(_source) {
88
- return wrapChild((0, node_child_process.fork)(CHILD_ENTRY_PATH, [], { serialization: "advanced" }));
100
+ function forkDecodeWorker(_source, leaseOverride = {}) {
101
+ return wrapChild((0, node_child_process.fork)(CHILD_ENTRY_PATH, [], {
102
+ serialization: "advanced",
103
+ env: {
104
+ ...process.env,
105
+ ...require_worker_protocol.nativeLeaseSettingEnv(leaseOverride)
106
+ }
107
+ }));
89
108
  }
90
109
  //#endregion
91
110
  //#region src/session-decode/guarded-session-decode.ts
@@ -305,7 +324,7 @@ var RETAINED_FRAME_BUDGET_BYTES = (() => {
305
324
  * missing thumbnails/firstFrames concentrated on exactly those cameras).
306
325
  * 16 frames ≈ 0.64 s @25 fps (1.6 s @10 fps). Only ACTIVE cameras hold frames,
307
326
  * so the worst-case protected footprint stays well inside the byte budget for
308
- * realistic concurrency (4 active cams ≈ 44 MB of 96); the hard ceiling always
327
+ * realistic concurrency (4 active cams ≈ 44 MB of 160); the hard ceiling always
309
328
  * overrides the floor regardless. Read ONCE from `CAMSTACK_SESSION_RETAIN_FLOOR`;
310
329
  * `0` disables the floor (pure global FIFO — the pre-2026-07-19 behaviour).
311
330
  */
@@ -657,6 +676,19 @@ var SessionDecodeSession = class {
657
676
  frameId
658
677
  });
659
678
  }
679
+ /**
680
+ * Tell the worker `frameId` reached inference, so its native lease is worth
681
+ * keeping. Fire and forget, no reply. Ordering matters and is free here: the
682
+ * caller runs in the same turn that consumed the frame, and the pull that
683
+ * decides this frame's admission is issued after that turn returns.
684
+ */
685
+ markInferred(frameId) {
686
+ if (this.exited) return;
687
+ this.trySend({
688
+ kind: "markInferred",
689
+ frameId
690
+ });
691
+ }
660
692
  /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
661
693
  teardown() {
662
694
  this.stopRequested = true;
@@ -877,6 +909,7 @@ function buildFrameImage(session, reply) {
877
909
  },
878
910
  nativeCrop: (bbox, maxWidth) => session.requestNativeCrop(reply.frameId, bbox, maxWidth),
879
911
  releaseNativeLease: () => session.releaseLease(reply.frameId),
912
+ markInferred: () => session.markInferred(reply.frameId),
880
913
  close: () => {
881
914
  closed = true;
882
915
  }
@@ -1006,7 +1039,10 @@ function startSessionDecodePump(deps) {
1006
1039
  const decoded = await toDecodedFrame(frame, format);
1007
1040
  const image = frame.image;
1008
1041
  const cropFn = image.nativeCrop;
1009
- const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), { release: () => image.releaseNativeLease?.() }) : void 0;
1042
+ const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), {
1043
+ release: () => image.releaseNativeLease?.(),
1044
+ markInferred: () => image.markInferred?.()
1045
+ }) : void 0;
1010
1046
  deps.onDecodedFrame(decoded, nativeCrop);
1011
1047
  } catch (err) {
1012
1048
  deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
@@ -1788,7 +1824,121 @@ var CropConventionSource = class {
1788
1824
  }
1789
1825
  };
1790
1826
  //#endregion
1827
+ //#region src/pipeline-runner/native-lease-settings-source.ts
1828
+ /**
1829
+ * Where the runner gets the cluster-wide native-frame LEASE knobs.
1830
+ *
1831
+ * ## Why a settings read and not the attach config
1832
+ *
1833
+ * Identical reasoning to `crop-convention-source.ts`, and worth restating
1834
+ * because the temptation is the same: `RunnerCameraConfig` is the obvious
1835
+ * orchestrator→runner channel and it is the wrong one. Eleven call sites build
1836
+ * one; a cluster-wide constant threaded through eleven builders eventually
1837
+ * reaches ten of them, and the cameras attached by the forgotten builder just
1838
+ * quietly run a different lease window. Instead the runner reads the OWNER
1839
+ * addon's global settings directly, hub-routed by `AddonCallGateway`, so an
1840
+ * agent runner transparently reads the hub-central store with no placement
1841
+ * branching.
1842
+ *
1843
+ * ## Deliberately NOT node-scoped
1844
+ *
1845
+ * The read passes no `nodeId`. With one, `addon-settings` would project that
1846
+ * node's scoped value and two nodes could hold different lease windows for the
1847
+ * same camera — so whether a stored crop is native pixels or the ≤640 fallback
1848
+ * would depend on where the balancer happened to place it, which is invisible
1849
+ * from the stored media.
1850
+ *
1851
+ * ## What it hands out
1852
+ *
1853
+ * A {@link NativeLeaseSettingsOverride} — only the knobs the operator SET. The
1854
+ * decode worker resolves the rest (env var, then the shipped default); see
1855
+ * `session-decode/native-lease-config.ts` for the precedence. Handing out a
1856
+ * fully-resolved triple here would erase the "operator set nothing" state and
1857
+ * with it the env-var escape hatch.
1858
+ */
1859
+ /**
1860
+ * The addon whose GLOBAL settings own the knobs. `pipeline-orchestrator` is
1861
+ * hub-resident and neutral — the same owner as the detail-crop convention and
1862
+ * the per-node decoder-backend selector.
1863
+ */
1864
+ var NATIVE_LEASE_OWNER_ADDON_ID = "pipeline-orchestrator";
1865
+ /**
1866
+ * Whether two overrides name the same knobs with the same values.
1867
+ *
1868
+ * **Every knob has to appear here.** This is the equality that decides whether
1869
+ * a re-read is ADOPTED at all: a knob missing from the comparison is read from
1870
+ * the hub, judged identical, and discarded, so the operator's change never
1871
+ * reaches a decode worker and nothing anywhere says so. `admission` shipped
1872
+ * missing from this list and did exactly that — the setting was stored, the
1873
+ * hub served it, the runner fetched it, and every worker kept spawning with the
1874
+ * old value.
1875
+ */
1876
+ function sameOverride(a, b) {
1877
+ return a.ttlMs === b.ttlMs && a.budgetMb === b.budgetMb && a.activityMs === b.activityMs && a.admission === b.admission;
1878
+ }
1879
+ /**
1880
+ * TTL-cached read of the cluster native-lease knobs.
1881
+ *
1882
+ * {@link current} is synchronous because the fork that consumes it is;
1883
+ * {@link refresh} is what talks to the hub, and the runner awaits it on the
1884
+ * path that precedes a decode-worker spawn.
1885
+ */
1886
+ var NativeLeaseSettingsSource = class {
1887
+ override = {};
1888
+ lastReadAtMs = Number.NEGATIVE_INFINITY;
1889
+ inFlight = null;
1890
+ logger;
1891
+ now;
1892
+ ttlMs;
1893
+ constructor(options) {
1894
+ this.logger = options.logger;
1895
+ this.now = options.now ?? (() => Date.now());
1896
+ this.ttlMs = options.ttlMs ?? 3e4;
1897
+ }
1898
+ /** The operator's overrides as last read — `{}` until a read lands. */
1899
+ current() {
1900
+ return this.override;
1901
+ }
1902
+ /**
1903
+ * Re-read if the cached value has expired. Concurrent callers share one
1904
+ * in-flight read; a failure keeps the LAST KNOWN value rather than reverting
1905
+ * to `{}`, because a revert would silently hand the next-spawned workers a
1906
+ * different RAM profile than the ones already running.
1907
+ */
1908
+ async refresh(api) {
1909
+ if (api === void 0) return this.override;
1910
+ if (this.now() - this.lastReadAtMs < this.ttlMs) return this.override;
1911
+ this.inFlight ??= this.read(api).finally(() => {
1912
+ this.inFlight = null;
1913
+ });
1914
+ await this.inFlight;
1915
+ return this.override;
1916
+ }
1917
+ async read(api) {
1918
+ try {
1919
+ const next = require_dist.pickNativeLeaseOverride(await api.addonSettings.getGlobalSettings.query({ addonId: NATIVE_LEASE_OWNER_ADDON_ID }));
1920
+ this.lastReadAtMs = this.now();
1921
+ if (sameOverride(next, this.override)) return;
1922
+ this.logger.info("native lease settings changed — applies to NEW decode sessions", { meta: {
1923
+ from: { ...this.override },
1924
+ to: { ...next }
1925
+ } });
1926
+ this.override = next;
1927
+ } catch (err) {
1928
+ this.logger.warn("native lease settings read failed — keeping the last known value", { meta: {
1929
+ owner: NATIVE_LEASE_OWNER_ADDON_ID,
1930
+ inUse: { ...this.override },
1931
+ error: err instanceof Error ? err.message : String(err)
1932
+ } });
1933
+ }
1934
+ }
1935
+ };
1936
+ //#endregion
1791
1937
  //#region src/pipeline-runner/detail-subtree.ts
1938
+ /** Did the native-lease fetch report a miss (rather than return pixels)? */
1939
+ function isNativeCropMiss(fetch) {
1940
+ return "miss" in fetch;
1941
+ }
1792
1942
  /** Normalize a frame-space pixel rect to `[0,1]` by the given frame dims. */
1793
1943
  function normalizeRect(rect, frameWidth, frameHeight) {
1794
1944
  return {
@@ -1874,6 +2024,15 @@ function encodeEmbeddingBase64(embedding) {
1874
2024
  function pickLabel(detection) {
1875
2025
  return detection.labels.find((l) => !l.label.startsWith("embedding:"))?.label;
1876
2026
  }
2027
+ /**
2028
+ * Turn one executor detection into the cap's `DetailResult`, reprojecting its
2029
+ * crop-space bbox back onto the frame the crop came from.
2030
+ *
2031
+ * Exported because the STATELESS step path (`stateless-step.ts`) produces the
2032
+ * same shape from the same executor output and must not grow its own mapping —
2033
+ * a second reprojection is a second set of coordinates, and the two would agree
2034
+ * right up until one of them changed.
2035
+ */
1877
2036
  function toDetailResult(detection, stepId, crop) {
1878
2037
  const label = pickLabel(detection);
1879
2038
  return {
@@ -1893,6 +2052,13 @@ function toDetailResult(detection, stepId, crop) {
1893
2052
  * traffic: before it existed the only way to feed stored pixels in was a
1894
2053
  * pre-cut tile, so the caller decided the rectangle and every caller decided
1895
2054
  * differently.
2055
+ *
2056
+ * Exported so the STATELESS step path can reach `deriveDetailCropRect` through
2057
+ * this function rather than importing it — `scripts/check-clip-crop-single-owner.ts`
2058
+ * permits exactly one importer, and that is the enforcement of
2059
+ * [D52](../../../../docs/decisions/adr-0052.md). A second caller of the
2060
+ * derivation is a second cutter no matter how similar its arithmetic looks
2061
+ * today.
1896
2062
  */
1897
2063
  async function cutFromFullFrame(frameJpegBase64, bbox, convention) {
1898
2064
  const image = (await require_lazy_sharp.getSharp())(Buffer.from(frameJpegBase64, "base64"));
@@ -1900,7 +2066,7 @@ async function cutFromFullFrame(frameJpegBase64, bbox, convention) {
1900
2066
  const frameWidth = meta.width ?? 0;
1901
2067
  const frameHeight = meta.height ?? 0;
1902
2068
  if (frameWidth <= 0 || frameHeight <= 0) return null;
1903
- const rect = require_dist.deriveDetailCropRect(bbox, frameWidth, frameHeight, convention);
2069
+ const rect = require_dist.deriveDetailCropRect(bbox.kind === "frame-pixels" ? bbox.rect : denormalizeRect(bbox.rect, frameWidth, frameHeight), frameWidth, frameHeight, convention);
1904
2070
  const left = Math.max(0, Math.min(Math.round(rect.x), frameWidth - 1));
1905
2071
  const top = Math.max(0, Math.min(Math.round(rect.y), frameHeight - 1));
1906
2072
  const width = Math.max(1, Math.min(Math.round(rect.w), frameWidth - left));
@@ -1924,37 +2090,82 @@ async function cutFromFullFrame(frameJpegBase64, bbox, convention) {
1924
2090
  };
1925
2091
  }
1926
2092
  async function resolveCrop(deps, input, convention) {
2093
+ let nativeMiss = null;
1927
2094
  if (input.frameHandle) {
1928
2095
  const normalized = normalizeRect(require_dist.deriveDetailCropRect(input.parent.bbox, input.frameHandle.width, input.frameHandle.height, convention), input.frameHandle.width, input.frameHandle.height);
1929
2096
  const native = await deps.getNativeCrop(input.deviceId, input.frameHandle, normalized);
1930
- if (native) return {
1931
- ...native,
1932
- source: "native"
1933
- };
2097
+ if (native !== null) {
2098
+ if (!isNativeCropMiss(native)) return {
2099
+ crop: {
2100
+ ...native,
2101
+ source: "native"
2102
+ },
2103
+ nativeMiss
2104
+ };
2105
+ nativeMiss = native;
2106
+ }
1934
2107
  }
1935
- if (input.frameJpeg) return cutFromFullFrame(input.frameJpeg, input.parent.bbox, convention);
2108
+ if (input.frameJpeg) return {
2109
+ crop: await cutFromFullFrame(input.frameJpeg, {
2110
+ kind: "frame-pixels",
2111
+ rect: input.parent.bbox
2112
+ }, convention),
2113
+ nativeMiss
2114
+ };
1936
2115
  if (input.cropJpeg) {
1937
2116
  const jpeg = Buffer.from(input.cropJpeg, "base64");
1938
2117
  const meta = await (await require_lazy_sharp.getSharp())(jpeg).metadata();
1939
2118
  const width = meta.width ?? 0;
1940
2119
  const height = meta.height ?? 0;
1941
- if (width <= 0 || height <= 0) return null;
2120
+ if (width <= 0 || height <= 0) return {
2121
+ crop: null,
2122
+ nativeMiss
2123
+ };
1942
2124
  return {
1943
- jpeg,
1944
- width,
1945
- height,
1946
- frameSpace: input.parent.bbox,
1947
- source: "fallback"
2125
+ crop: {
2126
+ jpeg,
2127
+ width,
2128
+ height,
2129
+ frameSpace: input.parent.bbox,
2130
+ source: "fallback"
2131
+ },
2132
+ nativeMiss
1948
2133
  };
1949
2134
  }
1950
- return null;
2135
+ return {
2136
+ crop: null,
2137
+ nativeMiss
2138
+ };
1951
2139
  }
1952
2140
  /**
1953
- * Run the DETAIL subtree for a single tracked detection: resolve a crop
1954
- * (native lease `cropJpeg` fallback), run each matched top-level child
1955
- * step's chain against it, and map every result bbox back to frame space.
1956
- * Returns `null` when the camera isn't attached, no configured child
1957
- * matches `input.parent.className`, or no crop source resolves.
2141
+ * Run the DETAIL subtree for a single tracked detection: resolve a crop, run
2142
+ * each matched top-level child step's chain against it, and map every result
2143
+ * bbox back to frame space. Returns `null` when the camera isn't attached, no
2144
+ * configured child matches `input.parent.className`, or no crop source
2145
+ * resolves.
2146
+ *
2147
+ * ## The three pixel sources, in the order they are tried
2148
+ *
2149
+ * The distinction between them is not the pixels — it is WHICH SIDE derives the
2150
+ * rectangle, and conflating that is how one vector index came to hold two
2151
+ * feature spaces ([D52](../../../../docs/decisions/adr-0052.md)):
2152
+ *
2153
+ * 1. `frameHandle` — the node-local native lease. Cheapest when it hits (no
2154
+ * payload crosses anything) and the only rung that yields a `nativeCropRef`,
2155
+ * so leaf children re-cut their ROI at native resolution. **The runner cuts.**
2156
+ * 2. `frameJpeg` — a FULL frame the caller still holds. **The runner cuts**, with
2157
+ * the same `deriveDetailCropRect` and the same convention, so the rectangle is
2158
+ * identical to what rung 1 would have asked for. Reached when the lease has
2159
+ * already expired — which, for the live detail plane, is the common case: the
2160
+ * dispatcher's tick plus a 500–900 ms inference routinely outlives a 1.2 s
2161
+ * lease, and before this rung was wired the step was simply abandoned.
2162
+ * 3. `cropJpeg` — an ALREADY-CUT tile, used verbatim, no padding and no squaring.
2163
+ * **The caller decided the rectangle**, so it is outside the single-derivation
2164
+ * guarantee and is only for the lease-miss retry.
2165
+ *
2166
+ * A rung-1 miss is remembered even when a lower rung succeeds, and travels into
2167
+ * the drop line below: "the native lease never hits any more" is precisely the
2168
+ * condition that goes invisible once something else covers for it.
1958
2169
  *
1959
2170
  * STRICT `steps` semantics: when the caller names an explicit `input.steps`
1960
2171
  * set, each matched root's descendant chain is pruned to exactly those ids
@@ -1990,7 +2201,7 @@ async function runDetailSubtree(deps, input) {
1990
2201
  }
1991
2202
  const requested = input.steps;
1992
2203
  const steps = requested === void 0 ? matched : matched.map((step) => pruneChildStepsToRequested(step, requested));
1993
- const crop = await resolveCrop(deps, input, deps.resolveCropConvention(input.deviceId));
2204
+ const { crop, nativeMiss } = await resolveCrop(deps, input, deps.resolveCropConvention(input.deviceId));
1994
2205
  if (!crop) {
1995
2206
  deps.logger.warn("runDetailSubtree: no crop source resolved — the dispatch produced nothing", {
1996
2207
  tags: { deviceId: input.deviceId },
@@ -1998,7 +2209,9 @@ async function runDetailSubtree(deps, input) {
1998
2209
  parentClass: input.parent.className,
1999
2210
  hadFrameHandle: input.frameHandle !== void 0,
2000
2211
  hadFrameJpeg: input.frameJpeg !== void 0,
2001
- hadCropJpeg: input.cropJpeg !== void 0
2212
+ hadCropJpeg: input.cropJpeg !== void 0,
2213
+ nativeMissReason: nativeMiss?.miss ?? null,
2214
+ nativeHandleAgeMs: nativeMiss?.handleAgeMs ?? null
2002
2215
  }
2003
2216
  });
2004
2217
  return null;
@@ -2953,16 +3166,24 @@ var PipelineRunner = class {
2953
3166
  handle
2954
3167
  });
2955
3168
  }
3169
+ /**
3170
+ * Returns whether the frame was ADMITTED to the detection queue — i.e.
3171
+ * whether it will reach inference. The caller uses that to mark the frame's
3172
+ * native lease worth keeping (`NativeCropRequester.markInferred`): a native
3173
+ * crop can only ever name a frameId that rode an inference result, so a
3174
+ * frame rejected here is one nobody can ask for. A `false` here is the whole
3175
+ * saving; do not turn it back into `void` for tidiness.
3176
+ */
2956
3177
  enqueueDetectionFrame(deviceId, frame, handle) {
2957
3178
  const state = this.cameras.get(deviceId);
2958
- if (!state) return;
3179
+ if (!state) return false;
2959
3180
  this.arrivalCounts.set(deviceId, (this.arrivalCounts.get(deviceId) ?? 0) + 1);
2960
- if (state.phase !== "active") return;
3181
+ if (state.phase !== "active") return false;
2961
3182
  const interval = this.governor.minIntervalMs(deviceId);
2962
3183
  if (interval > 0) {
2963
3184
  const last = this.lastAdmittedAt.get(deviceId) ?? 0;
2964
3185
  const now = Date.now();
2965
- if (now - last < interval) return;
3186
+ if (now - last < interval) return false;
2966
3187
  this.lastAdmittedAt.set(deviceId, now);
2967
3188
  }
2968
3189
  frame._enqueuedAt = Date.now();
@@ -2970,6 +3191,7 @@ var PipelineRunner = class {
2970
3191
  frame,
2971
3192
  handle
2972
3193
  });
3194
+ return true;
2973
3195
  }
2974
3196
  /**
2975
3197
  * `handle` is optional (mirrors {@link DetectionQueueEntry.handle}) — the
@@ -2981,13 +3203,14 @@ var PipelineRunner = class {
2981
3203
  */
2982
3204
  enqueueOccupancyFrame(deviceId, frame, handle) {
2983
3205
  const state = this.cameras.get(deviceId);
2984
- if (!state) return;
2985
- if (state.phase !== "watching") return;
3206
+ if (!state) return false;
3207
+ if (state.phase !== "watching") return false;
2986
3208
  frame._enqueuedAt = Date.now();
2987
3209
  state.detectionQueue.enqueue({
2988
3210
  frame,
2989
3211
  handle
2990
3212
  });
3213
+ return true;
2991
3214
  }
2992
3215
  /**
2993
3216
  * Report a motion event for a camera. Drives the unified phase
@@ -3397,6 +3620,83 @@ var PipelineRunner = class {
3397
3620
  }
3398
3621
  };
3399
3622
  //#endregion
3623
+ //#region src/pipeline-runner/stateless-step.ts
3624
+ function modelServability(step, modelId, format) {
3625
+ const entry = step.models.find((m) => m.id === modelId);
3626
+ if (entry === void 0) return { kind: "custom-model" };
3627
+ if (entry.formats[format] !== void 0) return { kind: "catalog-build" };
3628
+ return {
3629
+ kind: "no-build",
3630
+ availableFormats: Object.keys(entry.formats)
3631
+ };
3632
+ }
3633
+ /** Build the refusal answer, log it, and return it — never one without the other. */
3634
+ function refuse(deps, input, reason, detail) {
3635
+ deps.logger.warn("runStatelessStep refused", {
3636
+ tags: { deviceId: input.sourceDeviceId },
3637
+ meta: {
3638
+ nodeId: deps.nodeId,
3639
+ stepId: input.stepId,
3640
+ modelId: input.modelId,
3641
+ reason,
3642
+ detail
3643
+ }
3644
+ });
3645
+ return {
3646
+ kind: "refused",
3647
+ nodeId: deps.nodeId,
3648
+ reason,
3649
+ detail
3650
+ };
3651
+ }
3652
+ function errorMessage(err) {
3653
+ return err instanceof Error ? err.message : String(err);
3654
+ }
3655
+ /**
3656
+ * Run one step over one subject. See the module header for the two invariants.
3657
+ *
3658
+ * Never throws: every failure mode is a named, logged refusal, because the
3659
+ * caller is a bulk pass whose counters are the only signal an operator gets and
3660
+ * a thrown error there reads as "the whole pass broke".
3661
+ */
3662
+ async function runStatelessStep(deps, input) {
3663
+ const definition = deps.lookupStep(input.stepId);
3664
+ if (definition === null) return refuse(deps, input, "unknown-step", `this node's step catalog has no "${input.stepId}" — version skew, or a custom step that is not installed here`);
3665
+ if (input.bbox.w <= 0 || input.bbox.h <= 0) return refuse(deps, input, "unreadable-frame", `degenerate normalized bbox ${JSON.stringify(input.bbox)} — nothing to crop`);
3666
+ const format = await deps.executionFormat();
3667
+ if (format === null) return refuse(deps, input, "model-not-servable", "this node could not report the engine format it would dispatch on, so the model pin cannot be verified — running anyway would risk a silent format substitution");
3668
+ const servability = modelServability(definition, input.modelId, format);
3669
+ if (servability.kind === "no-build") return refuse(deps, input, "model-not-servable", `model "${input.modelId}" has no ${format} build (catalog ships: ${servability.availableFormats.join(", ") || "none"}) — this node would have substituted the ${format} default`);
3670
+ let decodeError = null;
3671
+ const crop = await cutFromFullFrame(input.frameJpeg, {
3672
+ kind: "normalized",
3673
+ rect: input.bbox
3674
+ }, deps.resolveCropConvention()).catch((err) => {
3675
+ decodeError = errorMessage(err);
3676
+ return null;
3677
+ });
3678
+ if (crop === null) return refuse(deps, input, "unreadable-frame", decodeError ?? "the supplied frame decoded to no readable dimensions");
3679
+ const steps = [{
3680
+ addonId: input.stepId,
3681
+ modelId: input.modelId,
3682
+ enabled: true
3683
+ }];
3684
+ let result;
3685
+ try {
3686
+ result = await deps.runPipeline(steps, crop.jpeg, input.sourceDeviceId);
3687
+ } catch (err) {
3688
+ return refuse(deps, input, "execution-failed", errorMessage(err));
3689
+ }
3690
+ if (result === null) return refuse(deps, input, "execution-failed", "the executor returned no frame result for the crop");
3691
+ const details = result.detections.map((detection) => toDetailResult(detection, input.stepId, crop));
3692
+ return {
3693
+ kind: "ran",
3694
+ nodeId: deps.nodeId,
3695
+ modelId: input.modelId,
3696
+ details
3697
+ };
3698
+ }
3699
+ //#endregion
3400
3700
  //#region src/pipeline-runner/index.ts
3401
3701
  var DEFAULT_CONFIG = {
3402
3702
  maxQueueDepth: 30,
@@ -4673,13 +4973,38 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4673
4973
  * resolve legitimately want the lower-RES honest frame).
4674
4974
  */
4675
4975
  async getNativeCrop(input) {
4976
+ const resolved = await this.resolveNativeCropPixels(input);
4977
+ if (resolved.tier === "native" && resolved.pixels !== null) return this.encodeNativeCropResult(resolved.pixels, resolved.tier, input.encodeJpeg);
4978
+ if (resolved.tier !== "ram-fullframe") return null;
4979
+ const crop = this.retainedFrames?.crop(input.handle, input.bbox, input.maxWidth);
4980
+ if (!crop) return null;
4981
+ return this.encodeNativeCropResult({
4982
+ bytes: crop.bytes,
4983
+ width: crop.width,
4984
+ height: crop.height
4985
+ }, resolved.tier, input.encodeJpeg);
4986
+ }
4987
+ /**
4988
+ * The native-lease half of {@link getNativeCrop}, extracted so the DETAIL path
4989
+ * can keep the miss REASON the cap's `null` return has no room for. Both
4990
+ * callers share this one decision — a second copy of the tier arithmetic is
4991
+ * how a quality tier starts differing by caller.
4992
+ *
4993
+ * Logging level follows the CONSEQUENCE, not the event: a native miss that the
4994
+ * RAM tier is allowed to serve (a full-frame resolve) cost nobody anything and
4995
+ * stays at debug; a miss the caller is handed as `null` is lost work and is a
4996
+ * warn, carrying the numbers that decide the fix.
4997
+ */
4998
+ async resolveNativeCropPixels(input) {
4676
4999
  const b = input.bbox;
4677
5000
  const isFullFrame = b.x <= .001 && b.y <= .001 && b.w >= .999 && b.h >= .999;
4678
5001
  const isUnboundedFullFrame = isFullFrame && input.maxWidth === void 0;
4679
5002
  let nativeResult = null;
4680
5003
  let missReason = null;
5004
+ let handleAgeMs = null;
4681
5005
  if (!isUnboundedFullFrame) {
4682
5006
  const found = this.nativeCropRegistry.get(input.handle);
5007
+ handleAgeMs = found?.ageMs ?? null;
4683
5008
  if (found) {
4684
5009
  const result = await found.request(input.bbox, input.maxWidth);
4685
5010
  if (result) nativeResult = {
@@ -4689,28 +5014,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4689
5014
  };
4690
5015
  else missReason = "worker-lease-gone";
4691
5016
  } else missReason = "no-registry-entry";
4692
- if (missReason !== null) this.ctx.logger.debug("native crop miss", {
4693
- tags: { deviceId: found?.deviceId ?? this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
4694
- meta: {
4695
- reason: missReason,
4696
- handle: `${input.handle.shmId}#${input.handle.slot}#${input.handle.seq}`,
4697
- handleNodeId: input.handle.nodeId,
4698
- handleAgeMs: found?.ageMs ?? null,
4699
- registrySize: this.nativeCropRegistry.size,
4700
- registryCap: DEFAULT_NATIVE_CROP_REGISTRY_CAP
4701
- }
4702
- });
5017
+ if (missReason !== null) {
5018
+ const tierOnMiss = resolveNativeCropSource(isFullFrame, false);
5019
+ const fields = {
5020
+ tags: { deviceId: found?.deviceId ?? this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
5021
+ meta: {
5022
+ reason: missReason,
5023
+ servedTier: tierOnMiss,
5024
+ handle: `${input.handle.shmId}#${input.handle.slot}#${input.handle.seq}`,
5025
+ handleNodeId: input.handle.nodeId,
5026
+ handleAgeMs,
5027
+ registrySize: this.nativeCropRegistry.size,
5028
+ registryCap: DEFAULT_NATIVE_CROP_REGISTRY_CAP
5029
+ }
5030
+ };
5031
+ if (tierOnMiss === "miss") this.ctx.logger.warn("native crop miss", fields);
5032
+ else this.ctx.logger.debug("native crop miss", fields);
5033
+ }
4703
5034
  }
4704
- const tier = resolveNativeCropSource(isFullFrame, nativeResult !== null);
4705
- if (tier === "native" && nativeResult) return this.encodeNativeCropResult(nativeResult, tier, input.encodeJpeg);
4706
- if (tier !== "ram-fullframe") return null;
4707
- const crop = this.retainedFrames?.crop(input.handle, input.bbox, input.maxWidth);
4708
- if (!crop) return null;
4709
- return this.encodeNativeCropResult({
4710
- bytes: crop.bytes,
4711
- width: crop.width,
4712
- height: crop.height
4713
- }, tier, input.encodeJpeg);
5035
+ return {
5036
+ pixels: nativeResult,
5037
+ tier: resolveNativeCropSource(isFullFrame, nativeResult !== null),
5038
+ missReason,
5039
+ handleAgeMs
5040
+ };
4714
5041
  }
4715
5042
  /**
4716
5043
  * Shape a resolved RGB crop into a `getNativeCrop` result. Raw path (default):
@@ -4765,6 +5092,66 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4765
5092
  }, input);
4766
5093
  }
4767
5094
  /**
5095
+ * Stateless single-step execution — no attach, no handle, no device
5096
+ * affinity. Delegates the decision table (catalog lookup, MODEL PIN
5097
+ * servability, crop, dispatch) to the deps-injected pure function in
5098
+ * {@link runStatelessStepImpl}; this method only wires the real deps.
5099
+ *
5100
+ * Note what is NOT wired, deliberately: no `resolveChildSteps` (the step
5101
+ * comes from the catalog, not from an attachment) and no `deviceKey` /
5102
+ * device jump (the jump roster is part of the attach config). The step runs
5103
+ * on the node's default engine, which is exactly the engine
5104
+ * `executionFormat` reports — so the servability check and the dispatch
5105
+ * cannot disagree about which format was verified.
5106
+ */
5107
+ async runStatelessStep(input) {
5108
+ const convention = await this.cropConvention.refresh(this.ctxIfReady?.api);
5109
+ return runStatelessStep({
5110
+ nodeId: this.nodeId,
5111
+ lookupStep: (stepId) => tryGetStepDefinition(stepId),
5112
+ executionFormat: () => this.resolveNodeExecutionFormat(),
5113
+ resolveCropConvention: () => convention,
5114
+ runPipeline: (steps, imageJpeg, sourceDeviceId) => this.runStatelessPipeline(steps, imageJpeg, sourceDeviceId),
5115
+ logger: this.detailSubtreeLogger()
5116
+ }, input);
5117
+ }
5118
+ /**
5119
+ * The model format this node dispatches on when no `deviceKey` is given —
5120
+ * its selected engine's format. `null` on any failure, which the caller
5121
+ * turns into a refusal rather than a guess: a model pin that cannot be
5122
+ * checked is a substitution waiting to happen.
5123
+ */
5124
+ async resolveNodeExecutionFormat() {
5125
+ const api = this.ctxIfReady?.api;
5126
+ if (!api) return null;
5127
+ try {
5128
+ return (await api.pipelineExecutor.getSelectedEngine.query()).format;
5129
+ } catch (err) {
5130
+ this.ctxIfReady?.logger.warn("runStatelessStep: could not read this node selected engine — refusing rather than guessing a format", { meta: { error: require_dist.errMsg(err) } });
5131
+ return null;
5132
+ }
5133
+ }
5134
+ /**
5135
+ * Real `runPipeline` dep for the stateless path: node-local
5136
+ * `pipelineExecutor.runPipeline` on the `'full'` plane with NO `deviceKey`.
5137
+ *
5138
+ * `deviceId` is passed so the executor's own gate/substitution lines are
5139
+ * tagged with the source camera. It is a routing hint on other transports,
5140
+ * but not here: the executor is co-located in the `detection` group, so
5141
+ * `localProviderLink` answers in-process before any router sees it — the
5142
+ * call cannot leave the node this one was pinned to.
5143
+ */
5144
+ async runStatelessPipeline(steps, imageJpeg, sourceDeviceId) {
5145
+ const api = this.ctxIfReady?.api;
5146
+ if (!api) return null;
5147
+ return api.pipelineExecutor.runPipeline.mutate({
5148
+ steps: [...steps],
5149
+ image: new Uint8Array(imageJpeg),
5150
+ deviceId: sourceDeviceId,
5151
+ plane: "full"
5152
+ });
5153
+ }
5154
+ /**
4768
5155
  * The cluster-wide detail-crop convention — read from the orchestrator's
4769
5156
  * global settings, NOT from the attach config. See `crop-convention-source.ts`
4770
5157
  * for why eleven attach builders make the attach config the wrong carrier.
@@ -4773,6 +5160,34 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4773
5160
  info: (message, extras) => this.ctxIfReady?.logger.info(message, extras),
4774
5161
  warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras)
4775
5162
  } });
5163
+ /**
5164
+ * The cluster-wide native-frame LEASE knobs (TTL / RAM ceiling / demand
5165
+ * window) — read from the orchestrator's global settings for the same reason
5166
+ * as the crop convention above, and injected into each decode worker at fork
5167
+ * time. See `native-lease-settings-source.ts`.
5168
+ */
5169
+ nativeLease = new NativeLeaseSettingsSource({ logger: {
5170
+ info: (message, extras) => this.ctxIfReady?.logger.info(message, extras),
5171
+ warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras)
5172
+ } });
5173
+ /**
5174
+ * Fork a decode worker carrying the operator's lease knobs. Used in place of
5175
+ * a bare `forkDecodeWorker` reference at every session-decode spawn site: the
5176
+ * knobs are read at spawn, so a changed setting reaches NEW sessions and
5177
+ * leaves running ones alone (a live session would have to be re-dialled).
5178
+ */
5179
+ forkDecodeWorkerWithLease = (source) => forkDecodeWorker(source, this.nativeLease.current());
5180
+ /**
5181
+ * Refresh the lease knobs before a decode worker is spawned. Awaited on the
5182
+ * `acquire` leg of `startGuardedSessionDecode` (which always completes before
5183
+ * `startPump` forks), so the spawn sees a value at most one TTL stale rather
5184
+ * than whatever a background refresh happened to have landed.
5185
+ * `NativeLeaseSettingsSource.refresh` swallows its own failures, so this can
5186
+ * never break a dial.
5187
+ */
5188
+ async refreshNativeLeaseSettings() {
5189
+ await this.nativeLease.refresh(this.ctxIfReady?.api);
5190
+ }
4776
5191
  /** A warn sink that survives a not-yet-ready ctx (boot-order dispatches). */
4777
5192
  detailSubtreeLogger() {
4778
5193
  return { warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras) };
@@ -4855,18 +5270,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
4855
5270
  * lease (same path the `getNativeCrop` cap method above uses), encodes the
4856
5271
  * raw RGB crop to JPEG, and denormalizes `paddedBbox` back to frame-space
4857
5272
  * pixels for the `frameSpace` the caller maps result bboxes against.
5273
+ *
5274
+ * Returns the MISS REASON rather than a bare `null`. This is the only caller
5275
+ * that can carry it anywhere useful — the cap's return shape has no room for
5276
+ * it, and the detail plane's drop line is where an operator reads why an
5277
+ * enrichment dispatch produced nothing.
4858
5278
  */
4859
5279
  async getNativeCropForDetail(_deviceId, handle, paddedBbox) {
4860
- const native = await this.getNativeCrop({
5280
+ const resolved = await this.resolveNativeCropPixels({
4861
5281
  handle,
4862
- bbox: paddedBbox,
4863
- encodeJpeg: true
5282
+ bbox: paddedBbox
4864
5283
  });
4865
- if (!native?.jpeg) return null;
5284
+ if (resolved.pixels === null || resolved.tier !== "native") return {
5285
+ miss: resolved.missReason ?? "worker-lease-gone",
5286
+ handleAgeMs: resolved.handleAgeMs
5287
+ };
5288
+ const encoded = await this.encodeNativeCropResult(resolved.pixels, "native", true);
5289
+ if (!encoded?.jpeg) return {
5290
+ miss: "jpeg-encode-failed",
5291
+ handleAgeMs: resolved.handleAgeMs
5292
+ };
4866
5293
  return {
4867
- jpeg: Buffer.from(native.jpeg, "base64"),
4868
- width: native.width,
4869
- height: native.height,
5294
+ jpeg: Buffer.from(encoded.jpeg, "base64"),
5295
+ width: encoded.width,
5296
+ height: encoded.height,
4870
5297
  frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
4871
5298
  };
4872
5299
  }
@@ -5091,6 +5518,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5091
5518
  * Kept as a method so the two call sites need no change.
5092
5519
  */
5093
5520
  async acquireSessionDecodeRestream(api, deviceId, streamId, tag, hostname, ownerNodeId) {
5521
+ await this.refreshNativeLeaseSettings();
5094
5522
  return acquireSessionDecodeRestreamFrom(api, this.ctx.logger, {
5095
5523
  deviceId,
5096
5524
  streamId,
@@ -5132,8 +5560,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5132
5560
  */
5133
5561
  enqueueSharedDetectionFrame(deviceId, frame, handle) {
5134
5562
  const runner = this.runner;
5135
- if (!runner) return;
5136
- runner.enqueueDetectionFrame(deviceId, frame, handle);
5563
+ if (!runner) return false;
5564
+ const admitted = runner.enqueueDetectionFrame(deviceId, frame, handle);
5137
5565
  const sink = this.sharedMotionSinks.get(deviceId);
5138
5566
  if (sink) {
5139
5567
  const now = this.sharedSinkNow();
@@ -5142,6 +5570,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5142
5570
  runner.enqueueMotionFrame(deviceId, rgbFrameToGrayFrame(frame), void 0);
5143
5571
  }
5144
5572
  }
5573
+ return admitted;
5145
5574
  }
5146
5575
  /**
5147
5576
  * Epic C P1 detection path for a flagged camera: decode `rgb` in a
@@ -5197,7 +5626,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5197
5626
  codec: source.codec
5198
5627
  } });
5199
5628
  return startSessionDecodePump({
5200
- fork: forkDecodeWorker,
5629
+ fork: this.forkDecodeWorkerWithLease,
5201
5630
  logger: log,
5202
5631
  source,
5203
5632
  opts: {
@@ -5209,7 +5638,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5209
5638
  onDecodedFrame: (frame, nativeCrop) => {
5210
5639
  const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
5211
5640
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
5212
- this.enqueueSharedDetectionFrame(config.deviceId, frame, handle);
5641
+ if (this.enqueueSharedDetectionFrame(config.deviceId, frame, handle)) nativeCrop?.markInferred();
5213
5642
  }
5214
5643
  });
5215
5644
  }
@@ -5242,7 +5671,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5242
5671
  codec: source.codec
5243
5672
  } });
5244
5673
  return startSessionDecodePump({
5245
- fork: forkDecodeWorker,
5674
+ fork: this.forkDecodeWorkerWithLease,
5246
5675
  logger: log,
5247
5676
  source,
5248
5677
  opts: {
@@ -5714,7 +6143,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5714
6143
  logger: log,
5715
6144
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:occupancy:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
5716
6145
  startPump: (source) => startSessionDecodePump({
5717
- fork: forkDecodeWorker,
6146
+ fork: this.forkDecodeWorkerWithLease,
5718
6147
  logger: log,
5719
6148
  source,
5720
6149
  opts: {
@@ -5727,7 +6156,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends require_dist.BaseAdd
5727
6156
  if (settled) return;
5728
6157
  const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
5729
6158
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
5730
- runner.enqueueOccupancyFrame(deviceId, frame, handle);
6159
+ if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
5731
6160
  count++;
5732
6161
  if (count >= frames) {
5733
6162
  clearTimeout(safetyTimer);