@camstack/addon-pipeline 1.2.43 → 1.2.45

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 (40) hide show
  1. package/dist/audio-analyzer/index.js +1 -1
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/detection-pipeline/index.js +158 -81
  4. package/dist/detection-pipeline/index.mjs +158 -81
  5. package/dist/{dist-B-VVBzrL.mjs → dist-BDkEhx4Z.mjs} +1315 -127
  6. package/dist/{dist-UHbKZiSa.js → dist-QyZ7QSRP.js} +1386 -126
  7. package/dist/{event-loop-stall-monitor-C7_L6vHO.js → event-loop-stall-monitor-COEKVBQK.js} +53 -8
  8. package/dist/{event-loop-stall-monitor-DfKdC7G4.mjs → event-loop-stall-monitor-IAuNEdmG.mjs} +53 -8
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +342 -69
  12. package/dist/pipeline-runner/index.mjs +342 -69
  13. package/dist/recorder/index.js +189 -6
  14. package/dist/recorder/index.mjs +189 -6
  15. package/dist/session-decode/decode-worker-child.js +197 -24
  16. package/dist/session-decode/decode-worker-child.mjs +197 -24
  17. package/dist/stream-broker/_stub.js +2 -2
  18. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D7iQbuKY.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C68YfSUH.mjs} +2 -2
  19. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CT2bGWqM.mjs +26 -0
  20. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-54IklEep.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B3GxBnXM.mjs} +1 -1
  21. package/dist/stream-broker/{hostInit-CKYS9yZO.mjs → hostInit-BMCrULpc.mjs} +2 -2
  22. package/dist/stream-broker/index.js +4602 -1442
  23. package/dist/stream-broker/index.mjs +4601 -1442
  24. package/dist/stream-broker/remoteEntry.js +1 -1
  25. package/dist/worker-protocol-DFH_z2-q.mjs +244 -0
  26. package/dist/worker-protocol-le27Mw_8.js +279 -0
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-DxZ75vB8.js → MaskShapeCanvas-DI4BY7W2-C7bsz4cP.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-BBY2Ztz5.js → MotionZonesSettings-NcxxQN8r-BmhLinUd.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-BC-3QmHa.js → PrivacyMaskSettings-APgPLF7p-BJjyGWFy.js} +1 -1
  30. package/embed-dist/assets/index-DC63og2c.css +2 -0
  31. package/embed-dist/assets/{index-on1wyOnd.js → index-a4CMt-Uc.js} +12 -12
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +1 -1
  34. package/python/postprocessors/__init__.py +2 -0
  35. package/python/postprocessors/rfdetr.py +161 -0
  36. package/python/postprocessors/test_rfdetr.py +193 -0
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CCC9fqHH.mjs +0 -26
  38. package/dist/worker-protocol-D7RzZIla.mjs +0 -77
  39. package/dist/worker-protocol-DextwlTX.js +0 -94
  40. package/embed-dist/assets/index-CGdwTcwE.css +0 -2
@@ -1,6 +1,6 @@
1
- import { C as customAction, E as deriveDetailCropRect, I as pickDetailCropConvention, K as errMsg, R as pipelineRunnerCapability, T as defineCustomActions, Z as createEvent, c as DEFAULT_DETAIL_CROP_CONVENTION, ct as array, dt as lazy, ft as literal, gt as string, l as DEVICE_BACKEND_TO_FORMAT, lt as boolean, mt as object, nt as nodePin, pt as number, q as BaseAddon, st as _enum, vt as EventCategory } from "../dist-B-VVBzrL.mjs";
2
- import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-DfKdC7G4.mjs";
3
- import { t as isWorkerReply } from "../worker-protocol-D7RzZIla.mjs";
1
+ import { $ as errMsg, A as deriveDetailCropRect, At as EventCategory, Ct as lazy, D as customAction, Et as object, H as pickNativeLeaseOverride, Ot as string, Tt as number, V as pickDetailCropConvention, W as pipelineRunnerCapability, at as BaseAddon, bt as array, k as defineCustomActions, l as DEVICE_BACKEND_TO_FORMAT, lt as createEvent, mt as nodePin, s as DEFAULT_DETAIL_CROP_CONVENTION, wt as literal, xt as boolean, yt as _enum } from "../dist-BDkEhx4Z.mjs";
2
+ import { c as getStepDefinition, t as attributeStall } from "../event-loop-stall-monitor-IAuNEdmG.mjs";
3
+ import { a as nativeLeaseSettingEnv, t as isWorkerReply } from "../worker-protocol-DFH_z2-q.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 {
@@ -1932,40 +2082,82 @@ async function cutFromFullFrame(frameJpegBase64, bbox, convention) {
1932
2082
  };
1933
2083
  }
1934
2084
  async function resolveCrop(deps, input, convention) {
2085
+ let nativeMiss = null;
1935
2086
  if (input.frameHandle) {
1936
2087
  const normalized = normalizeRect(deriveDetailCropRect(input.parent.bbox, input.frameHandle.width, input.frameHandle.height, convention), input.frameHandle.width, input.frameHandle.height);
1937
2088
  const native = await deps.getNativeCrop(input.deviceId, input.frameHandle, normalized);
1938
- if (native) return {
1939
- ...native,
1940
- source: "native"
1941
- };
2089
+ if (native !== null) {
2090
+ if (!isNativeCropMiss(native)) return {
2091
+ crop: {
2092
+ ...native,
2093
+ source: "native"
2094
+ },
2095
+ nativeMiss
2096
+ };
2097
+ nativeMiss = native;
2098
+ }
1942
2099
  }
1943
- if (input.frameJpeg) return cutFromFullFrame(input.frameJpeg, {
1944
- kind: "frame-pixels",
1945
- rect: input.parent.bbox
1946
- }, 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
+ };
1947
2107
  if (input.cropJpeg) {
1948
2108
  const jpeg = Buffer.from(input.cropJpeg, "base64");
1949
2109
  const meta = await (await getSharp())(jpeg).metadata();
1950
2110
  const width = meta.width ?? 0;
1951
2111
  const height = meta.height ?? 0;
1952
- if (width <= 0 || height <= 0) return null;
2112
+ if (width <= 0 || height <= 0) return {
2113
+ crop: null,
2114
+ nativeMiss
2115
+ };
1953
2116
  return {
1954
- jpeg,
1955
- width,
1956
- height,
1957
- frameSpace: input.parent.bbox,
1958
- source: "fallback"
2117
+ crop: {
2118
+ jpeg,
2119
+ width,
2120
+ height,
2121
+ frameSpace: input.parent.bbox,
2122
+ source: "fallback"
2123
+ },
2124
+ nativeMiss
1959
2125
  };
1960
2126
  }
1961
- return null;
2127
+ return {
2128
+ crop: null,
2129
+ nativeMiss
2130
+ };
1962
2131
  }
1963
2132
  /**
1964
- * Run the DETAIL subtree for a single tracked detection: resolve a crop
1965
- * (native lease `cropJpeg` fallback), run each matched top-level child
1966
- * step's chain against it, and map every result bbox back to frame space.
1967
- * Returns `null` when the camera isn't attached, no configured child
1968
- * 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.
1969
2161
  *
1970
2162
  * STRICT `steps` semantics: when the caller names an explicit `input.steps`
1971
2163
  * set, each matched root's descendant chain is pruned to exactly those ids
@@ -2001,7 +2193,7 @@ async function runDetailSubtree(deps, input) {
2001
2193
  }
2002
2194
  const requested = input.steps;
2003
2195
  const steps = requested === void 0 ? matched : matched.map((step) => pruneChildStepsToRequested(step, requested));
2004
- const crop = await resolveCrop(deps, input, deps.resolveCropConvention(input.deviceId));
2196
+ const { crop, nativeMiss } = await resolveCrop(deps, input, deps.resolveCropConvention(input.deviceId));
2005
2197
  if (!crop) {
2006
2198
  deps.logger.warn("runDetailSubtree: no crop source resolved — the dispatch produced nothing", {
2007
2199
  tags: { deviceId: input.deviceId },
@@ -2009,7 +2201,9 @@ async function runDetailSubtree(deps, input) {
2009
2201
  parentClass: input.parent.className,
2010
2202
  hadFrameHandle: input.frameHandle !== void 0,
2011
2203
  hadFrameJpeg: input.frameJpeg !== void 0,
2012
- hadCropJpeg: input.cropJpeg !== void 0
2204
+ hadCropJpeg: input.cropJpeg !== void 0,
2205
+ nativeMissReason: nativeMiss?.miss ?? null,
2206
+ nativeHandleAgeMs: nativeMiss?.handleAgeMs ?? null
2013
2207
  }
2014
2208
  });
2015
2209
  return null;
@@ -2964,16 +3158,24 @@ var PipelineRunner = class {
2964
3158
  handle
2965
3159
  });
2966
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
+ */
2967
3169
  enqueueDetectionFrame(deviceId, frame, handle) {
2968
3170
  const state = this.cameras.get(deviceId);
2969
- if (!state) return;
3171
+ if (!state) return false;
2970
3172
  this.arrivalCounts.set(deviceId, (this.arrivalCounts.get(deviceId) ?? 0) + 1);
2971
- if (state.phase !== "active") return;
3173
+ if (state.phase !== "active") return false;
2972
3174
  const interval = this.governor.minIntervalMs(deviceId);
2973
3175
  if (interval > 0) {
2974
3176
  const last = this.lastAdmittedAt.get(deviceId) ?? 0;
2975
3177
  const now = Date.now();
2976
- if (now - last < interval) return;
3178
+ if (now - last < interval) return false;
2977
3179
  this.lastAdmittedAt.set(deviceId, now);
2978
3180
  }
2979
3181
  frame._enqueuedAt = Date.now();
@@ -2981,6 +3183,7 @@ var PipelineRunner = class {
2981
3183
  frame,
2982
3184
  handle
2983
3185
  });
3186
+ return true;
2984
3187
  }
2985
3188
  /**
2986
3189
  * `handle` is optional (mirrors {@link DetectionQueueEntry.handle}) — the
@@ -2992,13 +3195,14 @@ var PipelineRunner = class {
2992
3195
  */
2993
3196
  enqueueOccupancyFrame(deviceId, frame, handle) {
2994
3197
  const state = this.cameras.get(deviceId);
2995
- if (!state) return;
2996
- if (state.phase !== "watching") return;
3198
+ if (!state) return false;
3199
+ if (state.phase !== "watching") return false;
2997
3200
  frame._enqueuedAt = Date.now();
2998
3201
  state.detectionQueue.enqueue({
2999
3202
  frame,
3000
3203
  handle
3001
3204
  });
3205
+ return true;
3002
3206
  }
3003
3207
  /**
3004
3208
  * Report a motion event for a camera. Drives the unified phase
@@ -4761,13 +4965,38 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4761
4965
  * resolve legitimately want the lower-RES honest frame).
4762
4966
  */
4763
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) {
4764
4991
  const b = input.bbox;
4765
4992
  const isFullFrame = b.x <= .001 && b.y <= .001 && b.w >= .999 && b.h >= .999;
4766
4993
  const isUnboundedFullFrame = isFullFrame && input.maxWidth === void 0;
4767
4994
  let nativeResult = null;
4768
4995
  let missReason = null;
4996
+ let handleAgeMs = null;
4769
4997
  if (!isUnboundedFullFrame) {
4770
4998
  const found = this.nativeCropRegistry.get(input.handle);
4999
+ handleAgeMs = found?.ageMs ?? null;
4771
5000
  if (found) {
4772
5001
  const result = await found.request(input.bbox, input.maxWidth);
4773
5002
  if (result) nativeResult = {
@@ -4777,28 +5006,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4777
5006
  };
4778
5007
  else missReason = "worker-lease-gone";
4779
5008
  } else missReason = "no-registry-entry";
4780
- if (missReason !== null) this.ctx.logger.debug("native crop miss", {
4781
- tags: { deviceId: found?.deviceId ?? this.nativeCropRegistry.deviceIdFor(input.handle) ?? -1 },
4782
- meta: {
4783
- reason: missReason,
4784
- handle: `${input.handle.shmId}#${input.handle.slot}#${input.handle.seq}`,
4785
- handleNodeId: input.handle.nodeId,
4786
- handleAgeMs: found?.ageMs ?? null,
4787
- registrySize: this.nativeCropRegistry.size,
4788
- registryCap: DEFAULT_NATIVE_CROP_REGISTRY_CAP
4789
- }
4790
- });
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
+ }
4791
5026
  }
4792
- const tier = resolveNativeCropSource(isFullFrame, nativeResult !== null);
4793
- if (tier === "native" && nativeResult) return this.encodeNativeCropResult(nativeResult, tier, input.encodeJpeg);
4794
- if (tier !== "ram-fullframe") return null;
4795
- const crop = this.retainedFrames?.crop(input.handle, input.bbox, input.maxWidth);
4796
- if (!crop) return null;
4797
- return this.encodeNativeCropResult({
4798
- bytes: crop.bytes,
4799
- width: crop.width,
4800
- height: crop.height
4801
- }, tier, input.encodeJpeg);
5027
+ return {
5028
+ pixels: nativeResult,
5029
+ tier: resolveNativeCropSource(isFullFrame, nativeResult !== null),
5030
+ missReason,
5031
+ handleAgeMs
5032
+ };
4802
5033
  }
4803
5034
  /**
4804
5035
  * Shape a resolved RGB crop into a `getNativeCrop` result. Raw path (default):
@@ -4921,6 +5152,34 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
4921
5152
  info: (message, extras) => this.ctxIfReady?.logger.info(message, extras),
4922
5153
  warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras)
4923
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
+ }
4924
5183
  /** A warn sink that survives a not-yet-ready ctx (boot-order dispatches). */
4925
5184
  detailSubtreeLogger() {
4926
5185
  return { warn: (message, extras) => this.ctxIfReady?.logger.warn(message, extras) };
@@ -5003,18 +5262,30 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5003
5262
  * lease (same path the `getNativeCrop` cap method above uses), encodes the
5004
5263
  * raw RGB crop to JPEG, and denormalizes `paddedBbox` back to frame-space
5005
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.
5006
5270
  */
5007
5271
  async getNativeCropForDetail(_deviceId, handle, paddedBbox) {
5008
- const native = await this.getNativeCrop({
5272
+ const resolved = await this.resolveNativeCropPixels({
5009
5273
  handle,
5010
- bbox: paddedBbox,
5011
- encodeJpeg: true
5274
+ bbox: paddedBbox
5012
5275
  });
5013
- 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
+ };
5014
5285
  return {
5015
- jpeg: Buffer.from(native.jpeg, "base64"),
5016
- width: native.width,
5017
- height: native.height,
5286
+ jpeg: Buffer.from(encoded.jpeg, "base64"),
5287
+ width: encoded.width,
5288
+ height: encoded.height,
5018
5289
  frameSpace: denormalizeRect(paddedBbox, handle.width, handle.height)
5019
5290
  };
5020
5291
  }
@@ -5239,6 +5510,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5239
5510
  * Kept as a method so the two call sites need no change.
5240
5511
  */
5241
5512
  async acquireSessionDecodeRestream(api, deviceId, streamId, tag, hostname, ownerNodeId) {
5513
+ await this.refreshNativeLeaseSettings();
5242
5514
  return acquireSessionDecodeRestreamFrom(api, this.ctx.logger, {
5243
5515
  deviceId,
5244
5516
  streamId,
@@ -5280,8 +5552,8 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5280
5552
  */
5281
5553
  enqueueSharedDetectionFrame(deviceId, frame, handle) {
5282
5554
  const runner = this.runner;
5283
- if (!runner) return;
5284
- runner.enqueueDetectionFrame(deviceId, frame, handle);
5555
+ if (!runner) return false;
5556
+ const admitted = runner.enqueueDetectionFrame(deviceId, frame, handle);
5285
5557
  const sink = this.sharedMotionSinks.get(deviceId);
5286
5558
  if (sink) {
5287
5559
  const now = this.sharedSinkNow();
@@ -5290,6 +5562,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5290
5562
  runner.enqueueMotionFrame(deviceId, rgbFrameToGrayFrame(frame), void 0);
5291
5563
  }
5292
5564
  }
5565
+ return admitted;
5293
5566
  }
5294
5567
  /**
5295
5568
  * Epic C P1 detection path for a flagged camera: decode `rgb` in a
@@ -5345,7 +5618,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5345
5618
  codec: source.codec
5346
5619
  } });
5347
5620
  return startSessionDecodePump({
5348
- fork: forkDecodeWorker,
5621
+ fork: this.forkDecodeWorkerWithLease,
5349
5622
  logger: log,
5350
5623
  source,
5351
5624
  opts: {
@@ -5357,7 +5630,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5357
5630
  onDecodedFrame: (frame, nativeCrop) => {
5358
5631
  const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
5359
5632
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
5360
- this.enqueueSharedDetectionFrame(config.deviceId, frame, handle);
5633
+ if (this.enqueueSharedDetectionFrame(config.deviceId, frame, handle)) nativeCrop?.markInferred();
5361
5634
  }
5362
5635
  });
5363
5636
  }
@@ -5390,7 +5663,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5390
5663
  codec: source.codec
5391
5664
  } });
5392
5665
  return startSessionDecodePump({
5393
- fork: forkDecodeWorker,
5666
+ fork: this.forkDecodeWorkerWithLease,
5394
5667
  logger: log,
5395
5668
  source,
5396
5669
  opts: {
@@ -5862,7 +6135,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5862
6135
  logger: log,
5863
6136
  acquire: () => this.acquireSessionDecodeRestream(api, config.deviceId, config.detectionStreamId, `session-decode:occupancy:${config.deviceId}`, resolveSessionDecodeHostname(config, process.env["CAMSTACK_HUB_URL"]), resolveSessionDecodeOwnerNodeId(config)),
5864
6137
  startPump: (source) => startSessionDecodePump({
5865
- fork: forkDecodeWorker,
6138
+ fork: this.forkDecodeWorkerWithLease,
5866
6139
  logger: log,
5867
6140
  source,
5868
6141
  opts: {
@@ -5875,7 +6148,7 @@ var PipelineRunnerAddon = class PipelineRunnerAddon extends BaseAddon {
5875
6148
  if (settled) return;
5876
6149
  const handle = retainedFrames?.write(config.deviceId, frame) ?? void 0;
5877
6150
  if (handle && nativeCrop) this.nativeCropRegistry.register(handle, nativeCrop, config.deviceId);
5878
- runner.enqueueOccupancyFrame(deviceId, frame, handle);
6151
+ if (runner.enqueueOccupancyFrame(deviceId, frame, handle)) nativeCrop?.markInferred();
5879
6152
  count++;
5880
6153
  if (count >= frames) {
5881
6154
  clearTimeout(safetyTimer);