@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
@@ -1,6 +1,6 @@
1
- import { $ as nodePin, C as defineCustomActions, F as pipelineRunnerCapability, J as createEvent, N as pickDetailCropConvention, U as errMsg, W as BaseAddon, at as array, c as DEVICE_BACKEND_TO_FORMAT, ct as lazy, dt as object, ht as EventCategory, it as _enum, lt as literal, ot as boolean, pt as string, s as DEFAULT_DETAIL_CROP_CONVENTION, ut as number, w as deriveDetailCropRect, x as customAction } from "../dist-COK_1Ot8.mjs";
2
- import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-CWOJtTNc.mjs";
3
- import { t as isWorkerReply } from "../worker-protocol-D7RzZIla.mjs";
1
+ import { Ct as literal, Dt as string, G as pickDetailCropConvention, J as pipelineRunnerCapability, K as pickNativeLeaseOverride, M as deriveDetailCropRect, St as lazy, Tt as object, bt as boolean, c as DEFAULT_DETAIL_CROP_CONVENTION, ct as createEvent, it as BaseAddon, j as defineCustomActions, k as customAction, kt as EventCategory, pt as nodePin, rt as errMsg, u as DEVICE_BACKEND_TO_FORMAT, vt as _enum, wt as number, yt as array } from "../dist-DOu93i_g.mjs";
2
+ import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-CShgQE6l.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-BOXlUhWO.mjs";
4
4
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
5
5
  import { t as getSharp } from "../lazy-sharp-B-mb8uWx.mjs";
6
6
  import { n as profileForStreamId, t as isRemoteRestream } from "../remote-restream-BeHi78PZ.mjs";
@@ -76,9 +76,28 @@ function wrapChild(child) {
76
76
  * Fork one decode-worker child. `source` is part of the coordinator's fork
77
77
  * signature (it forwards the same source in the subsequent `start` message);
78
78
  * the child entry itself is source-independent, so it is not consumed here.
79
+ *
80
+ * `leaseOverride` carries the operator's cluster-wide native-lease settings
81
+ * (`pipeline-orchestrator` globals, read by `native-lease-settings-source.ts`)
82
+ * into the child as environment variables — the child constructs its lease
83
+ * store and demand gate at module load, before the first `start` message, so
84
+ * the values have to be present at spawn. Only the knobs the operator actually
85
+ * SET are emitted, which is what keeps the pre-settings env override
86
+ * (`CAMSTACK_SESSION_NATIVE_LEASE_*`) working for the untouched ones; see
87
+ * `native-lease-config.ts` for the full precedence.
88
+ *
89
+ * The inherited environment is SPREAD, never replaced: this child resolves
90
+ * node-av through `CAMSTACK_FRAMEWORK_DIR` / `CAMSTACK_BUNDLED_ADDONS_DIR` on
91
+ * agents, so a replaced env would silently break decoding off the hub.
79
92
  */
80
- function forkDecodeWorker(_source) {
81
- return wrapChild(fork(CHILD_ENTRY_PATH, [], { serialization: "advanced" }));
93
+ function forkDecodeWorker(_source, leaseOverride = {}) {
94
+ return wrapChild(fork(CHILD_ENTRY_PATH, [], {
95
+ serialization: "advanced",
96
+ env: {
97
+ ...process.env,
98
+ ...nativeLeaseSettingEnv(leaseOverride)
99
+ }
100
+ }));
82
101
  }
83
102
  //#endregion
84
103
  //#region src/session-decode/guarded-session-decode.ts
@@ -298,7 +317,7 @@ var RETAINED_FRAME_BUDGET_BYTES = (() => {
298
317
  * missing thumbnails/firstFrames concentrated on exactly those cameras).
299
318
  * 16 frames ≈ 0.64 s @25 fps (1.6 s @10 fps). Only ACTIVE cameras hold frames,
300
319
  * so the worst-case protected footprint stays well inside the byte budget for
301
- * realistic concurrency (4 active cams ≈ 44 MB of 96); the hard ceiling always
320
+ * realistic concurrency (4 active cams ≈ 44 MB of 160); the hard ceiling always
302
321
  * overrides the floor regardless. Read ONCE from `CAMSTACK_SESSION_RETAIN_FLOOR`;
303
322
  * `0` disables the floor (pure global FIFO — the pre-2026-07-19 behaviour).
304
323
  */
@@ -650,6 +669,19 @@ var SessionDecodeSession = class {
650
669
  frameId
651
670
  });
652
671
  }
672
+ /**
673
+ * Tell the worker `frameId` reached inference, so its native lease is worth
674
+ * keeping. Fire and forget, no reply. Ordering matters and is free here: the
675
+ * caller runs in the same turn that consumed the frame, and the pull that
676
+ * decides this frame's admission is issued after that turn returns.
677
+ */
678
+ markInferred(frameId) {
679
+ if (this.exited) return;
680
+ this.trySend({
681
+ kind: "markInferred",
682
+ frameId
683
+ });
684
+ }
653
685
  /** Teardown: `stop`, `kill()`, arm the SIGKILL fallback unless already exited. */
654
686
  teardown() {
655
687
  this.stopRequested = true;
@@ -870,6 +902,7 @@ function buildFrameImage(session, reply) {
870
902
  },
871
903
  nativeCrop: (bbox, maxWidth) => session.requestNativeCrop(reply.frameId, bbox, maxWidth),
872
904
  releaseNativeLease: () => session.releaseLease(reply.frameId),
905
+ markInferred: () => session.markInferred(reply.frameId),
873
906
  close: () => {
874
907
  closed = true;
875
908
  }
@@ -999,7 +1032,10 @@ function startSessionDecodePump(deps) {
999
1032
  const decoded = await toDecodedFrame(frame, format);
1000
1033
  const image = frame.image;
1001
1034
  const cropFn = image.nativeCrop;
1002
- const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), { release: () => image.releaseNativeLease?.() }) : void 0;
1035
+ const nativeCrop = cropFn ? Object.assign((bbox, maxWidth) => cropFn(bbox, maxWidth), {
1036
+ release: () => image.releaseNativeLease?.(),
1037
+ markInferred: () => image.markInferred?.()
1038
+ }) : void 0;
1003
1039
  deps.onDecodedFrame(decoded, nativeCrop);
1004
1040
  } catch (err) {
1005
1041
  deps.logger.debug("session-decode pump: skipped a frame (decode/consume error)", {
@@ -1780,7 +1816,121 @@ var CropConventionSource = class {
1780
1816
  }
1781
1817
  };
1782
1818
  //#endregion
1819
+ //#region src/pipeline-runner/native-lease-settings-source.ts
1820
+ /**
1821
+ * Where the runner gets the cluster-wide native-frame LEASE knobs.
1822
+ *
1823
+ * ## Why a settings read and not the attach config
1824
+ *
1825
+ * Identical reasoning to `crop-convention-source.ts`, and worth restating
1826
+ * because the temptation is the same: `RunnerCameraConfig` is the obvious
1827
+ * orchestrator→runner channel and it is the wrong one. Eleven call sites build
1828
+ * one; a cluster-wide constant threaded through eleven builders eventually
1829
+ * reaches ten of them, and the cameras attached by the forgotten builder just
1830
+ * quietly run a different lease window. Instead the runner reads the OWNER
1831
+ * addon's global settings directly, hub-routed by `AddonCallGateway`, so an
1832
+ * agent runner transparently reads the hub-central store with no placement
1833
+ * branching.
1834
+ *
1835
+ * ## Deliberately NOT node-scoped
1836
+ *
1837
+ * The read passes no `nodeId`. With one, `addon-settings` would project that
1838
+ * node's scoped value and two nodes could hold different lease windows for the
1839
+ * same camera — so whether a stored crop is native pixels or the ≤640 fallback
1840
+ * would depend on where the balancer happened to place it, which is invisible
1841
+ * from the stored media.
1842
+ *
1843
+ * ## What it hands out
1844
+ *
1845
+ * A {@link NativeLeaseSettingsOverride} — only the knobs the operator SET. The
1846
+ * decode worker resolves the rest (env var, then the shipped default); see
1847
+ * `session-decode/native-lease-config.ts` for the precedence. Handing out a
1848
+ * fully-resolved triple here would erase the "operator set nothing" state and
1849
+ * with it the env-var escape hatch.
1850
+ */
1851
+ /**
1852
+ * The addon whose GLOBAL settings own the knobs. `pipeline-orchestrator` is
1853
+ * hub-resident and neutral — the same owner as the detail-crop convention and
1854
+ * the per-node decoder-backend selector.
1855
+ */
1856
+ var NATIVE_LEASE_OWNER_ADDON_ID = "pipeline-orchestrator";
1857
+ /**
1858
+ * Whether two overrides name the same knobs with the same values.
1859
+ *
1860
+ * **Every knob has to appear here.** This is the equality that decides whether
1861
+ * a re-read is ADOPTED at all: a knob missing from the comparison is read from
1862
+ * the hub, judged identical, and discarded, so the operator's change never
1863
+ * reaches a decode worker and nothing anywhere says so. `admission` shipped
1864
+ * missing from this list and did exactly that — the setting was stored, the
1865
+ * hub served it, the runner fetched it, and every worker kept spawning with the
1866
+ * old value.
1867
+ */
1868
+ function sameOverride(a, b) {
1869
+ return a.ttlMs === b.ttlMs && a.budgetMb === b.budgetMb && a.activityMs === b.activityMs && a.admission === b.admission;
1870
+ }
1871
+ /**
1872
+ * TTL-cached read of the cluster native-lease knobs.
1873
+ *
1874
+ * {@link current} is synchronous because the fork that consumes it is;
1875
+ * {@link refresh} is what talks to the hub, and the runner awaits it on the
1876
+ * path that precedes a decode-worker spawn.
1877
+ */
1878
+ var NativeLeaseSettingsSource = class {
1879
+ override = {};
1880
+ lastReadAtMs = Number.NEGATIVE_INFINITY;
1881
+ inFlight = null;
1882
+ logger;
1883
+ now;
1884
+ ttlMs;
1885
+ constructor(options) {
1886
+ this.logger = options.logger;
1887
+ this.now = options.now ?? (() => Date.now());
1888
+ this.ttlMs = options.ttlMs ?? 3e4;
1889
+ }
1890
+ /** The operator's overrides as last read — `{}` until a read lands. */
1891
+ current() {
1892
+ return this.override;
1893
+ }
1894
+ /**
1895
+ * Re-read if the cached value has expired. Concurrent callers share one
1896
+ * in-flight read; a failure keeps the LAST KNOWN value rather than reverting
1897
+ * to `{}`, because a revert would silently hand the next-spawned workers a
1898
+ * different RAM profile than the ones already running.
1899
+ */
1900
+ async refresh(api) {
1901
+ if (api === void 0) return this.override;
1902
+ if (this.now() - this.lastReadAtMs < this.ttlMs) return this.override;
1903
+ this.inFlight ??= this.read(api).finally(() => {
1904
+ this.inFlight = null;
1905
+ });
1906
+ await this.inFlight;
1907
+ return this.override;
1908
+ }
1909
+ async read(api) {
1910
+ try {
1911
+ const next = pickNativeLeaseOverride(await api.addonSettings.getGlobalSettings.query({ addonId: NATIVE_LEASE_OWNER_ADDON_ID }));
1912
+ this.lastReadAtMs = this.now();
1913
+ if (sameOverride(next, this.override)) return;
1914
+ this.logger.info("native lease settings changed — applies to NEW decode sessions", { meta: {
1915
+ from: { ...this.override },
1916
+ to: { ...next }
1917
+ } });
1918
+ this.override = next;
1919
+ } catch (err) {
1920
+ this.logger.warn("native lease settings read failed — keeping the last known value", { meta: {
1921
+ owner: NATIVE_LEASE_OWNER_ADDON_ID,
1922
+ inUse: { ...this.override },
1923
+ error: err instanceof Error ? err.message : String(err)
1924
+ } });
1925
+ }
1926
+ }
1927
+ };
1928
+ //#endregion
1783
1929
  //#region src/pipeline-runner/detail-subtree.ts
1930
+ /** Did the native-lease fetch report a miss (rather than return pixels)? */
1931
+ function isNativeCropMiss(fetch) {
1932
+ return "miss" in fetch;
1933
+ }
1784
1934
  /** Normalize a frame-space pixel rect to `[0,1]` by the given frame dims. */
1785
1935
  function normalizeRect(rect, frameWidth, frameHeight) {
1786
1936
  return {
@@ -1866,6 +2016,15 @@ function encodeEmbeddingBase64(embedding) {
1866
2016
  function pickLabel(detection) {
1867
2017
  return detection.labels.find((l) => !l.label.startsWith("embedding:"))?.label;
1868
2018
  }
2019
+ /**
2020
+ * Turn one executor detection into the cap's `DetailResult`, reprojecting its
2021
+ * crop-space bbox back onto the frame the crop came from.
2022
+ *
2023
+ * Exported because the STATELESS step path (`stateless-step.ts`) produces the
2024
+ * same shape from the same executor output and must not grow its own mapping —
2025
+ * a second reprojection is a second set of coordinates, and the two would agree
2026
+ * right up until one of them changed.
2027
+ */
1869
2028
  function toDetailResult(detection, stepId, crop) {
1870
2029
  const label = pickLabel(detection);
1871
2030
  return {
@@ -1885,6 +2044,13 @@ function toDetailResult(detection, stepId, crop) {
1885
2044
  * traffic: before it existed the only way to feed stored pixels in was a
1886
2045
  * pre-cut tile, so the caller decided the rectangle and every caller decided
1887
2046
  * differently.
2047
+ *
2048
+ * Exported so the STATELESS step path can reach `deriveDetailCropRect` through
2049
+ * this function rather than importing it — `scripts/check-clip-crop-single-owner.ts`
2050
+ * permits exactly one importer, and that is the enforcement of
2051
+ * [D52](../../../../docs/decisions/adr-0052.md). A second caller of the
2052
+ * derivation is a second cutter no matter how similar its arithmetic looks
2053
+ * today.
1888
2054
  */
1889
2055
  async function cutFromFullFrame(frameJpegBase64, bbox, convention) {
1890
2056
  const image = (await getSharp())(Buffer.from(frameJpegBase64, "base64"));
@@ -1892,7 +2058,7 @@ async function cutFromFullFrame(frameJpegBase64, bbox, convention) {
1892
2058
  const frameWidth = meta.width ?? 0;
1893
2059
  const frameHeight = meta.height ?? 0;
1894
2060
  if (frameWidth <= 0 || frameHeight <= 0) return null;
1895
- const rect = deriveDetailCropRect(bbox, frameWidth, frameHeight, convention);
2061
+ const rect = deriveDetailCropRect(bbox.kind === "frame-pixels" ? bbox.rect : denormalizeRect(bbox.rect, frameWidth, frameHeight), frameWidth, frameHeight, convention);
1896
2062
  const left = Math.max(0, Math.min(Math.round(rect.x), frameWidth - 1));
1897
2063
  const top = Math.max(0, Math.min(Math.round(rect.y), frameHeight - 1));
1898
2064
  const width = Math.max(1, Math.min(Math.round(rect.w), frameWidth - left));
@@ -1916,37 +2082,82 @@ async function cutFromFullFrame(frameJpegBase64, bbox, convention) {
1916
2082
  };
1917
2083
  }
1918
2084
  async function resolveCrop(deps, input, convention) {
2085
+ let nativeMiss = null;
1919
2086
  if (input.frameHandle) {
1920
2087
  const normalized = normalizeRect(deriveDetailCropRect(input.parent.bbox, input.frameHandle.width, input.frameHandle.height, convention), input.frameHandle.width, input.frameHandle.height);
1921
2088
  const native = await deps.getNativeCrop(input.deviceId, input.frameHandle, normalized);
1922
- if (native) return {
1923
- ...native,
1924
- source: "native"
1925
- };
2089
+ if (native !== null) {
2090
+ if (!isNativeCropMiss(native)) return {
2091
+ crop: {
2092
+ ...native,
2093
+ source: "native"
2094
+ },
2095
+ nativeMiss
2096
+ };
2097
+ nativeMiss = native;
2098
+ }
1926
2099
  }
1927
- if (input.frameJpeg) return cutFromFullFrame(input.frameJpeg, input.parent.bbox, convention);
2100
+ if (input.frameJpeg) return {
2101
+ crop: await cutFromFullFrame(input.frameJpeg, {
2102
+ kind: "frame-pixels",
2103
+ rect: input.parent.bbox
2104
+ }, convention),
2105
+ nativeMiss
2106
+ };
1928
2107
  if (input.cropJpeg) {
1929
2108
  const jpeg = Buffer.from(input.cropJpeg, "base64");
1930
2109
  const meta = await (await getSharp())(jpeg).metadata();
1931
2110
  const width = meta.width ?? 0;
1932
2111
  const height = meta.height ?? 0;
1933
- if (width <= 0 || height <= 0) return null;
2112
+ if (width <= 0 || height <= 0) return {
2113
+ crop: null,
2114
+ nativeMiss
2115
+ };
1934
2116
  return {
1935
- jpeg,
1936
- width,
1937
- height,
1938
- frameSpace: input.parent.bbox,
1939
- source: "fallback"
2117
+ crop: {
2118
+ jpeg,
2119
+ width,
2120
+ height,
2121
+ frameSpace: input.parent.bbox,
2122
+ source: "fallback"
2123
+ },
2124
+ nativeMiss
1940
2125
  };
1941
2126
  }
1942
- return null;
2127
+ return {
2128
+ crop: null,
2129
+ nativeMiss
2130
+ };
1943
2131
  }
1944
2132
  /**
1945
- * Run the DETAIL subtree for a single tracked detection: resolve a crop
1946
- * (native lease `cropJpeg` fallback), run each matched top-level child
1947
- * step's chain against it, and map every result bbox back to frame space.
1948
- * Returns `null` when the camera isn't attached, no configured child
1949
- * matches `input.parent.className`, or no crop source resolves.
2133
+ * Run the DETAIL subtree for a single tracked detection: resolve a crop, run
2134
+ * each matched top-level child step's chain against it, and map every result
2135
+ * bbox back to frame space. Returns `null` when the camera isn't attached, no
2136
+ * configured child matches `input.parent.className`, or no crop source
2137
+ * resolves.
2138
+ *
2139
+ * ## The three pixel sources, in the order they are tried
2140
+ *
2141
+ * The distinction between them is not the pixels — it is WHICH SIDE derives the
2142
+ * rectangle, and conflating that is how one vector index came to hold two
2143
+ * feature spaces ([D52](../../../../docs/decisions/adr-0052.md)):
2144
+ *
2145
+ * 1. `frameHandle` — the node-local native lease. Cheapest when it hits (no
2146
+ * payload crosses anything) and the only rung that yields a `nativeCropRef`,
2147
+ * so leaf children re-cut their ROI at native resolution. **The runner cuts.**
2148
+ * 2. `frameJpeg` — a FULL frame the caller still holds. **The runner cuts**, with
2149
+ * the same `deriveDetailCropRect` and the same convention, so the rectangle is
2150
+ * identical to what rung 1 would have asked for. Reached when the lease has
2151
+ * already expired — which, for the live detail plane, is the common case: the
2152
+ * dispatcher's tick plus a 500–900 ms inference routinely outlives a 1.2 s
2153
+ * lease, and before this rung was wired the step was simply abandoned.
2154
+ * 3. `cropJpeg` — an ALREADY-CUT tile, used verbatim, no padding and no squaring.
2155
+ * **The caller decided the rectangle**, so it is outside the single-derivation
2156
+ * guarantee and is only for the lease-miss retry.
2157
+ *
2158
+ * A rung-1 miss is remembered even when a lower rung succeeds, and travels into
2159
+ * the drop line below: "the native lease never hits any more" is precisely the
2160
+ * condition that goes invisible once something else covers for it.
1950
2161
  *
1951
2162
  * STRICT `steps` semantics: when the caller names an explicit `input.steps`
1952
2163
  * set, each matched root's descendant chain is pruned to exactly those ids
@@ -1982,7 +2193,7 @@ async function runDetailSubtree(deps, input) {
1982
2193
  }
1983
2194
  const requested = input.steps;
1984
2195
  const steps = requested === void 0 ? matched : matched.map((step) => pruneChildStepsToRequested(step, requested));
1985
- const crop = await resolveCrop(deps, input, deps.resolveCropConvention(input.deviceId));
2196
+ const { crop, nativeMiss } = await resolveCrop(deps, input, deps.resolveCropConvention(input.deviceId));
1986
2197
  if (!crop) {
1987
2198
  deps.logger.warn("runDetailSubtree: no crop source resolved — the dispatch produced nothing", {
1988
2199
  tags: { deviceId: input.deviceId },
@@ -1990,7 +2201,9 @@ async function runDetailSubtree(deps, input) {
1990
2201
  parentClass: input.parent.className,
1991
2202
  hadFrameHandle: input.frameHandle !== void 0,
1992
2203
  hadFrameJpeg: input.frameJpeg !== void 0,
1993
- hadCropJpeg: input.cropJpeg !== void 0
2204
+ hadCropJpeg: input.cropJpeg !== void 0,
2205
+ nativeMissReason: nativeMiss?.miss ?? null,
2206
+ nativeHandleAgeMs: nativeMiss?.handleAgeMs ?? null
1994
2207
  }
1995
2208
  });
1996
2209
  return null;
@@ -2945,16 +3158,24 @@ var PipelineRunner = class {
2945
3158
  handle
2946
3159
  });
2947
3160
  }
3161
+ /**
3162
+ * Returns whether the frame was ADMITTED to the detection queue — i.e.
3163
+ * whether it will reach inference. The caller uses that to mark the frame's
3164
+ * native lease worth keeping (`NativeCropRequester.markInferred`): a native
3165
+ * crop can only ever name a frameId that rode an inference result, so a
3166
+ * frame rejected here is one nobody can ask for. A `false` here is the whole
3167
+ * saving; do not turn it back into `void` for tidiness.
3168
+ */
2948
3169
  enqueueDetectionFrame(deviceId, frame, handle) {
2949
3170
  const state = this.cameras.get(deviceId);
2950
- if (!state) return;
3171
+ if (!state) return false;
2951
3172
  this.arrivalCounts.set(deviceId, (this.arrivalCounts.get(deviceId) ?? 0) + 1);
2952
- if (state.phase !== "active") return;
3173
+ if (state.phase !== "active") return false;
2953
3174
  const interval = this.governor.minIntervalMs(deviceId);
2954
3175
  if (interval > 0) {
2955
3176
  const last = this.lastAdmittedAt.get(deviceId) ?? 0;
2956
3177
  const now = Date.now();
2957
- if (now - last < interval) return;
3178
+ if (now - last < interval) return false;
2958
3179
  this.lastAdmittedAt.set(deviceId, now);
2959
3180
  }
2960
3181
  frame._enqueuedAt = Date.now();
@@ -2962,6 +3183,7 @@ var PipelineRunner = class {
2962
3183
  frame,
2963
3184
  handle
2964
3185
  });
3186
+ return true;
2965
3187
  }
2966
3188
  /**
2967
3189
  * `handle` is optional (mirrors {@link DetectionQueueEntry.handle}) — the
@@ -2973,13 +3195,14 @@ var PipelineRunner = class {
2973
3195
  */
2974
3196
  enqueueOccupancyFrame(deviceId, frame, handle) {
2975
3197
  const state = this.cameras.get(deviceId);
2976
- if (!state) return;
2977
- if (state.phase !== "watching") return;
3198
+ if (!state) return false;
3199
+ if (state.phase !== "watching") return false;
2978
3200
  frame._enqueuedAt = Date.now();
2979
3201
  state.detectionQueue.enqueue({
2980
3202
  frame,
2981
3203
  handle
2982
3204
  });
3205
+ return true;
2983
3206
  }
2984
3207
  /**
2985
3208
  * Report a motion event for a camera. Drives the unified phase
@@ -3389,6 +3612,83 @@ var PipelineRunner = class {
3389
3612
  }
3390
3613
  };
3391
3614
  //#endregion
3615
+ //#region src/pipeline-runner/stateless-step.ts
3616
+ function modelServability(step, modelId, format) {
3617
+ const entry = step.models.find((m) => m.id === modelId);
3618
+ if (entry === void 0) return { kind: "custom-model" };
3619
+ if (entry.formats[format] !== void 0) return { kind: "catalog-build" };
3620
+ return {
3621
+ kind: "no-build",
3622
+ availableFormats: Object.keys(entry.formats)
3623
+ };
3624
+ }
3625
+ /** Build the refusal answer, log it, and return it — never one without the other. */
3626
+ function refuse(deps, input, reason, detail) {
3627
+ deps.logger.warn("runStatelessStep refused", {
3628
+ tags: { deviceId: input.sourceDeviceId },
3629
+ meta: {
3630
+ nodeId: deps.nodeId,
3631
+ stepId: input.stepId,
3632
+ modelId: input.modelId,
3633
+ reason,
3634
+ detail
3635
+ }
3636
+ });
3637
+ return {
3638
+ kind: "refused",
3639
+ nodeId: deps.nodeId,
3640
+ reason,
3641
+ detail
3642
+ };
3643
+ }
3644
+ function errorMessage(err) {
3645
+ return err instanceof Error ? err.message : String(err);
3646
+ }
3647
+ /**
3648
+ * Run one step over one subject. See the module header for the two invariants.
3649
+ *
3650
+ * Never throws: every failure mode is a named, logged refusal, because the
3651
+ * caller is a bulk pass whose counters are the only signal an operator gets and
3652
+ * a thrown error there reads as "the whole pass broke".
3653
+ */
3654
+ async function runStatelessStep(deps, input) {
3655
+ const definition = deps.lookupStep(input.stepId);
3656
+ 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`);
3657
+ 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`);
3658
+ const format = await deps.executionFormat();
3659
+ 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");
3660
+ const servability = modelServability(definition, input.modelId, format);
3661
+ 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`);
3662
+ let decodeError = null;
3663
+ const crop = await cutFromFullFrame(input.frameJpeg, {
3664
+ kind: "normalized",
3665
+ rect: input.bbox
3666
+ }, deps.resolveCropConvention()).catch((err) => {
3667
+ decodeError = errorMessage(err);
3668
+ return null;
3669
+ });
3670
+ if (crop === null) return refuse(deps, input, "unreadable-frame", decodeError ?? "the supplied frame decoded to no readable dimensions");
3671
+ const steps = [{
3672
+ addonId: input.stepId,
3673
+ modelId: input.modelId,
3674
+ enabled: true
3675
+ }];
3676
+ let result;
3677
+ try {
3678
+ result = await deps.runPipeline(steps, crop.jpeg, input.sourceDeviceId);
3679
+ } catch (err) {
3680
+ return refuse(deps, input, "execution-failed", errorMessage(err));
3681
+ }
3682
+ if (result === null) return refuse(deps, input, "execution-failed", "the executor returned no frame result for the crop");
3683
+ const details = result.detections.map((detection) => toDetailResult(detection, input.stepId, crop));
3684
+ return {
3685
+ kind: "ran",
3686
+ nodeId: deps.nodeId,
3687
+ modelId: input.modelId,
3688
+ details
3689
+ };
3690
+ }
3691
+ //#endregion
3392
3692
  //#region src/pipeline-runner/index.ts
3393
3693
  var DEFAULT_CONFIG = {
3394
3694
  maxQueueDepth: 30,
@@ -4665,13 +4965,38 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4665
4965
  * resolve legitimately want the lower-RES honest frame).
4666
4966
  */
4667
4967
  async getNativeCrop(input) {
4968
+ const resolved = await this.resolveNativeCropPixels(input);
4969
+ if (resolved.tier === "native" && resolved.pixels !== null) return this.encodeNativeCropResult(resolved.pixels, resolved.tier, input.encodeJpeg);
4970
+ if (resolved.tier !== "ram-fullframe") return null;
4971
+ const crop = this.retainedFrames?.crop(input.handle, input.bbox, input.maxWidth);
4972
+ if (!crop) return null;
4973
+ return this.encodeNativeCropResult({
4974
+ bytes: crop.bytes,
4975
+ width: crop.width,
4976
+ height: crop.height
4977
+ }, resolved.tier, input.encodeJpeg);
4978
+ }
4979
+ /**
4980
+ * The native-lease half of {@link getNativeCrop}, extracted so the DETAIL path
4981
+ * can keep the miss REASON the cap's `null` return has no room for. Both
4982
+ * callers share this one decision — a second copy of the tier arithmetic is
4983
+ * how a quality tier starts differing by caller.
4984
+ *
4985
+ * Logging level follows the CONSEQUENCE, not the event: a native miss that the
4986
+ * RAM tier is allowed to serve (a full-frame resolve) cost nobody anything and
4987
+ * stays at debug; a miss the caller is handed as `null` is lost work and is a
4988
+ * warn, carrying the numbers that decide the fix.
4989
+ */
4990
+ async resolveNativeCropPixels(input) {
4668
4991
  const b = input.bbox;
4669
4992
  const isFullFrame = b.x <= .001 && b.y <= .001 && b.w >= .999 && b.h >= .999;
4670
4993
  const isUnboundedFullFrame = isFullFrame && input.maxWidth === void 0;
4671
4994
  let nativeResult = null;
4672
4995
  let missReason = null;
4996
+ let handleAgeMs = null;
4673
4997
  if (!isUnboundedFullFrame) {
4674
4998
  const found = this.nativeCropRegistry.get(input.handle);
4999
+ handleAgeMs = found?.ageMs ?? null;
4675
5000
  if (found) {
4676
5001
  const result = await found.request(input.bbox, input.maxWidth);
4677
5002
  if (result) nativeResult = {
@@ -4681,28 +5006,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4681
5006
  };
4682
5007
  else missReason = "worker-lease-gone";
4683
5008
  } else missReason = "no-registry-entry";
4684
- if (missReason !== null) this.ctx.logger.debug("native crop miss", {
4685
- tags: { deviceId: found?.deviceId ?? this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
4686
- meta: {
4687
- reason: missReason,
4688
- handle: `${input.handle.shmId}#${input.handle.slot}#${input.handle.seq}`,
4689
- handleNodeId: input.handle.nodeId,
4690
- handleAgeMs: found?.ageMs ?? null,
4691
- registrySize: this.nativeCropRegistry.size,
4692
- registryCap: DEFAULT_NATIVE_CROP_REGISTRY_CAP
4693
- }
4694
- });
5009
+ if (missReason !== null) {
5010
+ const tierOnMiss = resolveNativeCropSource(isFullFrame, false);
5011
+ const fields = {
5012
+ tags: { deviceId: found?.deviceId ?? this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
5013
+ meta: {
5014
+ reason: missReason,
5015
+ servedTier: tierOnMiss,
5016
+ handle: `${input.handle.shmId}#${input.handle.slot}#${input.handle.seq}`,
5017
+ handleNodeId: input.handle.nodeId,
5018
+ handleAgeMs,
5019
+ registrySize: this.nativeCropRegistry.size,
5020
+ registryCap: DEFAULT_NATIVE_CROP_REGISTRY_CAP
5021
+ }
5022
+ };
5023
+ if (tierOnMiss === "miss") this.ctx.logger.warn("native crop miss", fields);
5024
+ else this.ctx.logger.debug("native crop miss", fields);
5025
+ }
4695
5026
  }
4696
- const tier = resolveNativeCropSource(isFullFrame, nativeResult !== null);
4697
- if (tier === "native" && nativeResult) return this.encodeNativeCropResult(nativeResult, tier, input.encodeJpeg);
4698
- if (tier !== "ram-fullframe") return null;
4699
- const crop = this.retainedFrames?.crop(input.handle, input.bbox, input.maxWidth);
4700
- if (!crop) return null;
4701
- return this.encodeNativeCropResult({
4702
- bytes: crop.bytes,
4703
- width: crop.width,
4704
- height: crop.height
4705
- }, tier, input.encodeJpeg);
5027
+ return {
5028
+ pixels: nativeResult,
5029
+ tier: resolveNativeCropSource(isFullFrame, nativeResult !== null),
5030
+ missReason,
5031
+ handleAgeMs
5032
+ };
4706
5033
  }
4707
5034
  /**
4708
5035
  * Shape a resolved RGB crop into a `getNativeCrop` result. Raw path (default):
@@ -4757,6 +5084,66 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4757
5084
  }, input);
4758
5085
  }
4759
5086
  /**
5087
+ * Stateless single-step execution — no attach, no handle, no device
5088
+ * affinity. Delegates the decision table (catalog lookup, MODEL PIN
5089
+ * servability, crop, dispatch) to the deps-injected pure function in
5090
+ * {@link runStatelessStepImpl}; this method only wires the real deps.
5091
+ *
5092
+ * Note what is NOT wired, deliberately: no `resolveChildSteps` (the step
5093
+ * comes from the catalog, not from an attachment) and no `deviceKey` /
5094
+ * device jump (the jump roster is part of the attach config). The step runs
5095
+ * on the node's default engine, which is exactly the engine
5096
+ * `executionFormat` reports — so the servability check and the dispatch
5097
+ * cannot disagree about which format was verified.
5098
+ */
5099
+ async runStatelessStep(input) {
5100
+ const convention = await this.cropConvention.refresh(this.ctxIfReady?.api);
5101
+ return runStatelessStep({
5102
+ nodeId: this.nodeId,
5103
+ lookupStep: (stepId) => tryGetStepDefinition(stepId),
5104
+ executionFormat: () => this.resolveNodeExecutionFormat(),
5105
+ resolveCropConvention: () => convention,
5106
+ runPipeline: (steps, imageJpeg, sourceDeviceId) => this.runStatelessPipeline(steps, imageJpeg, sourceDeviceId),
5107
+ logger: this.detailSubtreeLogger()
5108
+ }, input);
5109
+ }
5110
+ /**
5111
+ * The model format this node dispatches on when no `deviceKey` is given —
5112
+ * its selected engine's format. `null` on any failure, which the caller
5113
+ * turns into a refusal rather than a guess: a model pin that cannot be
5114
+ * checked is a substitution waiting to happen.
5115
+ */
5116
+ async resolveNodeExecutionFormat() {
5117
+ const api = this.ctxIfReady?.api;
5118
+ if (!api) return null;
5119
+ try {
5120
+ return (await api.pipelineExecutor.getSelectedEngine.query()).format;
5121
+ } catch (err) {
5122
+ this.ctxIfReady?.logger.warn("runStatelessStep: could not read this node selected engine — refusing rather than guessing a format", { meta: { error: errMsg(err) } });
5123
+ return null;
5124
+ }
5125
+ }
5126
+ /**
5127
+ * Real `runPipeline` dep for the stateless path: node-local
5128
+ * `pipelineExecutor.runPipeline` on the `'full'` plane with NO `deviceKey`.
5129
+ *
5130
+ * `deviceId` is passed so the executor's own gate/substitution lines are
5131
+ * tagged with the source camera. It is a routing hint on other transports,
5132
+ * but not here: the executor is co-located in the `detection` group, so
5133
+ * `localProviderLink` answers in-process before any router sees it — the
5134
+ * call cannot leave the node this one was pinned to.
5135
+ */
5136
+ async runStatelessPipeline(steps, imageJpeg, sourceDeviceId) {
5137
+ const api = this.ctxIfReady?.api;
5138
+ if (!api) return null;
5139
+ return api.pipelineExecutor.runPipeline.mutate({
5140
+ steps: [...steps],
5141
+ image: new Uint8Array(imageJpeg),
5142
+ deviceId: sourceDeviceId,
5143
+ plane: "full"
5144
+ });
5145
+ }
5146
+ /**
4760
5147
  * The cluster-wide detail-crop convention — read from the orchestrator's
4761
5148
  * global settings, NOT from the attach config. See `crop-convention-source.ts`
4762
5149
  * for why eleven attach builders make the attach config the wrong carrier.
@@ -4765,6 +5152,34 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4765
5152
  info: (message, extras) => this.ctxIfReady?.logger.info(message, extras),
4766
5153
  warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras)
4767
5154
  } });
5155
+ /**
5156
+ * The cluster-wide native-frame LEASE knobs (TTL / RAM ceiling / demand
5157
+ * window) — read from the orchestrator's global settings for the same reason
5158
+ * as the crop convention above, and injected into each decode worker at fork
5159
+ * time. See `native-lease-settings-source.ts`.
5160
+ */
5161
+ nativeLease = new NativeLeaseSettingsSource({ logger: {
5162
+ info: (message, extras) => this.ctxIfReady?.logger.info(message, extras),
5163
+ warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras)
5164
+ } });
5165
+ /**
5166
+ * Fork a decode worker carrying the operator's lease knobs. Used in place of
5167
+ * a bare `forkDecodeWorker` reference at every session-decode spawn site: the
5168
+ * knobs are read at spawn, so a changed setting reaches NEW sessions and
5169
+ * leaves running ones alone (a live session would have to be re-dialled).
5170
+ */
5171
+ forkDecodeWorkerWithLease = (source) => forkDecodeWorker(source, this.nativeLease.current());
5172
+ /**
5173
+ * Refresh the lease knobs before a decode worker is spawned. Awaited on the
5174
+ * `acquire` leg of `startGuardedSessionDecode` (which always completes before
5175
+ * `startPump` forks), so the spawn sees a value at most one TTL stale rather
5176
+ * than whatever a background refresh happened to have landed.
5177
+ * `NativeLeaseSettingsSource.refresh` swallows its own failures, so this can
5178
+ * never break a dial.
5179
+ */
5180
+ async refreshNativeLeaseSettings() {
5181
+ await this.nativeLease.refresh(this.ctxIfReady?.api);
5182
+ }
4768
5183
  /** A warn sink that survives a not-yet-ready ctx (boot-order dispatches). */
4769
5184
  detailSubtreeLogger() {
4770
5185
  return { warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras) };
@@ -4847,18 +5262,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4847
5262
  * lease (same path the `getNativeCrop` cap method above uses), encodes the
4848
5263
  * raw RGB crop to JPEG, and denormalizes `paddedBbox` back to frame-space
4849
5264
  * pixels for the `frameSpace` the caller maps result bboxes against.
5265
+ *
5266
+ * Returns the MISS REASON rather than a bare `null`. This is the only caller
5267
+ * that can carry it anywhere useful — the cap's return shape has no room for
5268
+ * it, and the detail plane's drop line is where an operator reads why an
5269
+ * enrichment dispatch produced nothing.
4850
5270
  */
4851
5271
  async getNativeCropForDetail(_deviceId, handle, paddedBbox) {
4852
- const native = await this.getNativeCrop({
5272
+ const resolved = await this.resolveNativeCropPixels({
4853
5273
  handle,
4854
- bbox: paddedBbox,
4855
- encodeJpeg: true
5274
+ bbox: paddedBbox
4856
5275
  });
4857
- if (!native?.jpeg) return null;
5276
+ if (resolved.pixels === null || resolved.tier !== "native") return {
5277
+ miss: resolved.missReason ?? "worker-lease-gone",
5278
+ handleAgeMs: resolved.handleAgeMs
5279
+ };
5280
+ const encoded = await this.encodeNativeCropResult(resolved.pixels, "native", true);
5281
+ if (!encoded?.jpeg) return {
5282
+ miss: "jpeg-encode-failed",
5283
+ handleAgeMs: resolved.handleAgeMs
5284
+ };
4858
5285
  return {
4859
- jpeg: Buffer.from(native.jpeg, "base64"),
4860
- width: native.width,
4861
- height: native.height,
5286
+ jpeg: Buffer.from(encoded.jpeg, "base64"),
5287
+ width: encoded.width,
5288
+ height: encoded.height,
4862
5289
  frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
4863
5290
  };
4864
5291
  }
@@ -5083,6 +5510,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5083
5510
  * Kept as a method so the two call sites need no change.
5084
5511
  */
5085
5512
  async acquireSessionDecodeRestream(api, deviceId, streamId, tag, hostname, ownerNodeId) {
5513
+ await this.refreshNativeLeaseSettings();
5086
5514
  return acquireSessionDecodeRestreamFrom(api, this.ctx.logger, {
5087
5515
  deviceId,
5088
5516
  streamId,
@@ -5124,8 +5552,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5124
5552
  */
5125
5553
  enqueueSharedDetectionFrame(deviceId, frame, handle) {
5126
5554
  const runner = this.runner;
5127
- if (!runner) return;
5128
- runner.enqueueDetectionFrame(deviceId, frame, handle);
5555
+ if (!runner) return false;
5556
+ const admitted = runner.enqueueDetectionFrame(deviceId, frame, handle);
5129
5557
  const sink = this.sharedMotionSinks.get(deviceId);
5130
5558
  if (sink) {
5131
5559
  const now = this.sharedSinkNow();
@@ -5134,6 +5562,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5134
5562
  runner.enqueueMotionFrame(deviceId, rgbFrameToGrayFrame(frame), void 0);
5135
5563
  }
5136
5564
  }
5565
+ return admitted;
5137
5566
  }
5138
5567
  /**
5139
5568
  * Epic C P1 detection path for a flagged camera: decode `rgb` in a
@@ -5189,7 +5618,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5189
5618
  codec: source.codec
5190
5619
  } });
5191
5620
  return startSessionDecodePump({
5192
- fork: forkDecodeWorker,
5621
+ fork: this.forkDecodeWorkerWithLease,
5193
5622
  logger: log,
5194
5623
  source,
5195
5624
  opts: {
@@ -5201,7 +5630,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5201
5630
  onDecodedFrame: (frame, nativeCrop) => {
5202
5631
  const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
5203
5632
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
5204
- this.enqueueSharedDetectionFrame(config.deviceId, frame, handle);
5633
+ if (this.enqueueSharedDetectionFrame(config.deviceId, frame, handle)) nativeCrop?.markInferred();
5205
5634
  }
5206
5635
  });
5207
5636
  }
@@ -5234,7 +5663,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5234
5663
  codec: source.codec
5235
5664
  } });
5236
5665
  return startSessionDecodePump({
5237
- fork: forkDecodeWorker,
5666
+ fork: this.forkDecodeWorkerWithLease,
5238
5667
  logger: log,
5239
5668
  source,
5240
5669
  opts: {
@@ -5706,7 +6135,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5706
6135
  logger: log,
5707
6136
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:occupancy:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
5708
6137
  startPump: (source) => startSessionDecodePump({
5709
- fork: forkDecodeWorker,
6138
+ fork: this.forkDecodeWorkerWithLease,
5710
6139
  logger: log,
5711
6140
  source,
5712
6141
  opts: {
@@ -5719,7 +6148,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5719
6148
  if (settled) return;
5720
6149
  const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
5721
6150
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
5722
- runner.enqueueOccupancyFrame(deviceId, frame, handle);
6151
+ if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
5723
6152
  count++;
5724
6153
  if (count >= frames) {
5725
6154
  clearTimeout(safetyTimer);