@camstack/addon-pipeline 1.1.27 → 1.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/audio-analyzer/index.js +7 -6
  2. package/dist/audio-analyzer/index.mjs +7 -6
  3. package/dist/audio-codec-ffmpeg/index.js +1 -5
  4. package/dist/audio-codec-ffmpeg/index.mjs +1 -5
  5. package/dist/decoder-ffmpeg/index.js +138 -356
  6. package/dist/decoder-ffmpeg/index.mjs +118 -336
  7. package/dist/decoder-nodeav/index.js +17 -10
  8. package/dist/decoder-nodeav/index.mjs +17 -10
  9. package/dist/detection-pipeline/index.js +341 -379
  10. package/dist/detection-pipeline/index.mjs +341 -379
  11. package/dist/{dist-DAIlCdAx.js → dist-Cwc0TUQr.js} +378 -76
  12. package/dist/{dist-CgEP_0OL.mjs → dist-DjuGmyG9.mjs} +379 -65
  13. package/dist/ffmpeg-args-C5GPp8Cw.mjs +323 -0
  14. package/dist/ffmpeg-args-D6h1edXK.js +418 -0
  15. package/dist/frame-dropper-AjheBGMG.mjs +22 -0
  16. package/dist/frame-dropper-DKLM6pMz.js +27 -0
  17. package/dist/{frame-handle-plane-Dq20KtKL.mjs → frame-handle-plane-Bkxz-TTD.mjs} +13 -5
  18. package/dist/{frame-handle-plane-DtTRX_0n.js → frame-handle-plane-DQNCTrpC.js} +13 -5
  19. package/dist/{frame-ring-sink-BbzrqTWj.js → frame-ring-sink-8LLV-cvH.js} +128 -23
  20. package/dist/{frame-ring-sink-B_NvPTJZ.mjs → frame-ring-sink-ClEWjiRU.mjs} +117 -24
  21. package/dist/motion-wasm/index.js +55 -3
  22. package/dist/motion-wasm/index.mjs +55 -3
  23. package/dist/node-topology-platform-BkR_k6WT.mjs +15 -0
  24. package/dist/node-topology-platform-CFZ7F4xW.js +20 -0
  25. package/dist/pipeline-runner/index.js +50 -16
  26. package/dist/pipeline-runner/index.mjs +50 -16
  27. package/dist/recorder/index.js +36 -40
  28. package/dist/recorder/index.mjs +36 -40
  29. package/dist/stream-broker/_stub.js +2 -2
  30. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5tQlh9h4.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-D567z31g.mjs} +3 -3
  31. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DhY3MZ2C.mjs +26 -0
  32. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BJK0-svt.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DtoYGpTp.mjs} +1 -1
  33. package/dist/stream-broker/{hostInit-DyLqyJaS.mjs → hostInit-B_b3PIZB.mjs} +3 -3
  34. package/dist/stream-broker/index.js +198 -115
  35. package/dist/stream-broker/index.mjs +189 -106
  36. package/dist/stream-broker/remoteEntry.js +1 -1
  37. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js → MaskShapeCanvas-DI4BY7W2-x-DZOuQL.js} +1 -1
  38. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CoLjNiUN.js → MotionZonesSettings-NcxxQN8r-DTeuDGCU.js} +1 -1
  39. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js → PrivacyMaskSettings-APgPLF7p-CirtxO3e.js} +1 -1
  40. package/embed-dist/assets/index-C5UpuPr8.css +2 -0
  41. package/embed-dist/assets/{index-C-pL8ETk.js → index-CpUy8OIE.js} +10 -10
  42. package/embed-dist/index.html +2 -2
  43. package/package.json +1 -1
  44. package/python/inference_pool.py +93 -102
  45. package/python/test_inference_pool_device_selection.py +43 -38
  46. package/dist/frame-dropper-7RTo_YyG.js +0 -68
  47. package/dist/frame-dropper-CwkBTPGV.mjs +0 -51
  48. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CkOPfV8r.mjs +0 -26
  49. package/embed-dist/assets/index-DrJ0ee3f.css +0 -2
@@ -3,7 +3,9 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-DxM2DSns.js");
6
- const require_dist = require("../dist-DAIlCdAx.js");
6
+ const require_dist = require("../dist-Cwc0TUQr.js");
7
+ const require_node_topology_platform = require("../node-topology-platform-CFZ7F4xW.js");
8
+ let _camstack_shm_ring = require("@camstack/shm-ring");
7
9
  let node_fs = require("node:fs");
8
10
  node_fs = require_model_download_service_C_IHWnXx.__toESM(node_fs);
9
11
  let node_path = require("node:path");
@@ -13,98 +15,6 @@ node_os = require_model_download_service_C_IHWnXx.__toESM(node_os);
13
15
  let node_child_process = require("node:child_process");
14
16
  let sharp = require("sharp");
15
17
  sharp = require_model_download_service_C_IHWnXx.__toESM(sharp);
16
- //#region src/detection-pipeline/engine-store-keys.ts
17
- /**
18
- * Per-node scoping for the detection-pipeline engine cascade.
19
- *
20
- * The detection addon's settings store is a single CLUSTER-SHARED blob (the
21
- * settings-store cap is hub-resident; every node's detection instance reads and
22
- * writes the same keys). That is correct for node-agnostic settings (pipeline
23
- * steps, tuning) but WRONG for the engine cascade — `engineBackend` /
24
- * `engineDevice` / `probedBestEngine` are hardware-specific, so the hub (NPU)
25
- * and a remote agent (iGPU, or no accelerator at all) must hold INDEPENDENT
26
- * selections. Sharing them lets one node's pick (e.g. `openvino/npu`) override
27
- * another node that has no NPU.
28
- *
29
- * These three keys are therefore persisted node-scoped as `<key>@<nodeId>`.
30
- * Everything else in the store stays shared. A legacy un-scoped value (written
31
- * before this change, or by an older build) is read as a migration fallback
32
- * for the HUB ONLY — the pre-scoping store was only ever written by a
33
- * hub-only deployment, so serving the bare value to any other node leaks the
34
- * hub's hardware pick (e.g. `openvino/auto`) onto a node that cannot run it
35
- * (the CB3 "Mac runs OpenVINO" / N100 AUTO-device bug). A non-hub node with
36
- * no scoped key reads `undefined`, which drives its own auto-pick.
37
- */
38
- var ENGINE_CASCADE_KEYS = [
39
- "engineBackend",
40
- "engineDevice",
41
- "probedBestEngine"
42
- ];
43
- function isEngineCascadeKey(key) {
44
- return ENGINE_CASCADE_KEYS.includes(key);
45
- }
46
- /**
47
- * Normalise a raw kernel node id to the bare node id used for scoping.
48
- * `localNodeId` can carry a `<node>/<addon>` suffix; the engine selection is
49
- * per-NODE, so strip the addon segment. Falls back to `hub`.
50
- */
51
- function normalizeEngineNodeId(rawNodeId) {
52
- const raw = rawNodeId ?? "hub";
53
- return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
54
- }
55
- /** The node-scoped store key for an engine cascade field. */
56
- function nodeEngineKey(base, nodeId) {
57
- return `${base}@${normalizeEngineNodeId(nodeId)}`;
58
- }
59
- /**
60
- * Read an engine cascade value for a node: the node-scoped key if present,
61
- * otherwise — for the HUB only — the legacy un-scoped value (migration),
62
- * otherwise undefined. The bare fallback is hub-restricted because only the
63
- * hub ever wrote the pre-scoping store; serving it to other nodes leaked the
64
- * hub's engine selection cluster-wide (CB3). A non-hub node with no scoped
65
- * key gets `undefined` so its own platform-driven auto-pick runs and persists
66
- * a real per-node selection.
67
- */
68
- function readNodeEngineValue(store, base, nodeId) {
69
- const scoped = store[nodeEngineKey(base, nodeId)];
70
- if (scoped !== void 0) return scoped;
71
- return normalizeEngineNodeId(nodeId) === "hub" ? store[base] : void 0;
72
- }
73
- /**
74
- * Re-map a UI/settings patch so engine cascade fields persist under the
75
- * node-scoped key; all other keys pass through unchanged. Used on the write
76
- * path so a UI save on one node never clobbers another node's engine.
77
- */
78
- function scopeEnginePatch(patch, nodeId) {
79
- const out = {};
80
- for (const [key, value] of Object.entries(patch)) out[isEngineCascadeKey(key) ? nodeEngineKey(key, nodeId) : key] = value;
81
- return out;
82
- }
83
- /**
84
- * Project a raw store onto the plain engine cascade keys for THIS node, so the
85
- * UI schema (whose field keys are the bare `engineBackend` etc.) hydrates from
86
- * the node's own selection. Non-engine keys are left untouched. Node-scoped
87
- * keys for OTHER nodes are dropped from the projection (not relevant to this
88
- * node's form).
89
- */
90
- function projectNodeEngine(store, nodeId) {
91
- const out = {};
92
- const scopedForAnyNode = /* @__PURE__ */ new Set();
93
- for (const key of Object.keys(store)) {
94
- const atIdx = key.indexOf("@");
95
- if (isEngineCascadeKey(atIdx >= 0 ? key.slice(0, atIdx) : key)) {
96
- scopedForAnyNode.add(key);
97
- continue;
98
- }
99
- out[key] = store[key];
100
- }
101
- for (const base of ENGINE_CASCADE_KEYS) {
102
- const value = readNodeEngineValue(store, base, nodeId);
103
- if (value !== void 0) out[base] = value;
104
- }
105
- return out;
106
- }
107
- //#endregion
108
18
  //#region src/detection-pipeline/runtimes.ts
109
19
  var KNOWN_PLATFORMS = [
110
20
  "darwin",
@@ -276,6 +186,88 @@ function pickBestRuntime(env, bestBackendHint) {
276
186
  };
277
187
  }
278
188
  //#endregion
189
+ //#region src/detection-pipeline/engine-store-keys.ts
190
+ /**
191
+ * Per-node scoping for the detection-pipeline engine cascade.
192
+ *
193
+ * The detection addon's settings store is a single CLUSTER-SHARED blob (the
194
+ * settings-store cap is hub-resident; every node's detection instance reads and
195
+ * writes the same keys). That is correct for node-agnostic settings (pipeline
196
+ * steps, tuning) but WRONG for the engine cascade — `engineBackend` /
197
+ * `engineDevice` / `probedBestEngine` are hardware-specific, so the hub (NPU)
198
+ * and a remote agent (iGPU, or no accelerator at all) must hold INDEPENDENT
199
+ * selections. Sharing them lets one node's pick (e.g. `openvino/npu`) override
200
+ * another node that has no NPU.
201
+ *
202
+ * These three keys are therefore persisted node-scoped as `<key>@<nodeId>`.
203
+ * Everything else in the store stays shared. There is NO fallback to a legacy
204
+ * un-scoped value for ANY node (the hub included) and no in-code migration: a
205
+ * bare key (written before scoping, or by an older build) is inert — invisible
206
+ * to every node. A node with no scoped key reads `undefined`, which drives its
207
+ * OWN live resolve (`resolveAutoEngine` picks the node's runtime from the local
208
+ * kernel each boot; `reprobeEngine` persists an explicit probe under
209
+ * `<key>@<node>`). Serving a bare value to any node
210
+ * would leak one node's hardware pick (e.g. `openvino/auto`) onto a node that
211
+ * cannot run it (the CB3 "Mac runs OpenVINO" / N100 AUTO-device bug). This
212
+ * mirrors the decoder's `readNodeDecoderBackend` no-fallback rule.
213
+ */
214
+ var ENGINE_CASCADE_KEYS = [
215
+ "engineBackend",
216
+ "engineDevice",
217
+ "probedBestEngine"
218
+ ];
219
+ function isEngineCascadeKey(key) {
220
+ return ENGINE_CASCADE_KEYS.includes(key);
221
+ }
222
+ /**
223
+ * Normalise a raw kernel node id to the bare node id used for scoping.
224
+ * `localNodeId` can carry a `<node>/<addon>` suffix; the engine selection is
225
+ * per-NODE, so strip the addon segment. Falls back to `hub`.
226
+ */
227
+ function normalizeEngineNodeId(rawNodeId) {
228
+ const raw = rawNodeId ?? "hub";
229
+ return raw.includes("/") ? raw.split("/")[0] ?? "hub" : raw;
230
+ }
231
+ /** The node-scoped store key for an engine cascade field. */
232
+ function nodeEngineKey(base, nodeId) {
233
+ return `${base}@${normalizeEngineNodeId(nodeId)}`;
234
+ }
235
+ /**
236
+ * Read an engine cascade value for a node: the node-scoped key if present,
237
+ * otherwise `undefined` — for EVERY node, the hub included. There is no
238
+ * fallback to the legacy un-scoped key: a bare value in the store is inert.
239
+ * A node with no scoped key gets `undefined` so its own platform-driven
240
+ * auto-pick runs and persists a real per-node selection under `<key>@<node>`.
241
+ * (A bare fallback would leak one node's engine selection cluster-wide — CB3.)
242
+ */
243
+ function readNodeEngineValue(store, base, nodeId) {
244
+ return store[nodeEngineKey(base, nodeId)];
245
+ }
246
+ /**
247
+ * Project a raw store onto the plain engine cascade keys for THIS node, so the
248
+ * UI schema (whose field keys are the bare `engineBackend` etc.) hydrates from
249
+ * the node's own selection. Non-engine keys are left untouched. Node-scoped
250
+ * keys for OTHER nodes are dropped from the projection (not relevant to this
251
+ * node's form).
252
+ */
253
+ function projectNodeEngine(store, nodeId) {
254
+ const out = {};
255
+ const scopedForAnyNode = /* @__PURE__ */ new Set();
256
+ for (const key of Object.keys(store)) {
257
+ const atIdx = key.indexOf("@");
258
+ if (isEngineCascadeKey(atIdx >= 0 ? key.slice(0, atIdx) : key)) {
259
+ scopedForAnyNode.add(key);
260
+ continue;
261
+ }
262
+ out[key] = store[key];
263
+ }
264
+ for (const base of ENGINE_CASCADE_KEYS) {
265
+ const value = readNodeEngineValue(store, base, nodeId);
266
+ if (value !== void 0) out[base] = value;
267
+ }
268
+ return out;
269
+ }
270
+ //#endregion
279
271
  //#region src/detection-pipeline/engine/shared-inference-pool.ts
280
272
  /**
281
273
  * SharedInferencePool — TypeScript wrapper for inference_pool.py.
@@ -4348,6 +4340,15 @@ var ONNX_FLOOR = {
4348
4340
  device: "cpu"
4349
4341
  };
4350
4342
  /**
4343
+ * How long the first-boot pick waits for the node's `platform-probe` to flip
4344
+ * `ready` (= its FULL async probe done, embedded-Python install included).
4345
+ * MUST stay finite — `readinessRegistry.awaitReady` defaults to Infinity,
4346
+ * which would wedge `setApi` (and the addon's onInitialize) forever on a
4347
+ * node whose probe never completes. On timeout the pick proceeds
4348
+ * conservatively (platform floor, never persisted when non-authoritative).
4349
+ */
4350
+ var PROBE_READY_TIMEOUT_MS = 12e4;
4351
+ /**
4351
4352
  * Build the onnx-cpu floor pick using `pickBestRuntime` with a null hardware
4352
4353
  * env. Used wherever the old `detectBestEngine()` sync probe fell back — the
4353
4354
  * result is identical (onnx / cpu) but is now derived through the shared rules
@@ -4426,6 +4427,16 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4426
4427
  */
4427
4428
  lastStepsSignature = null;
4428
4429
  /**
4430
+ * CB5 shm passthrough — the process-local, LONG-LIVED reader cache for
4431
+ * `runPipeline({ frameHandle })`. Opens each named shm segment ONCE and
4432
+ * reuses the mapping for every later handle on the same ring (never
4433
+ * open/close per-call). Lazily created on first handle, unmapped in
4434
+ * {@link shutdown}. The executor and the runner are both hub-local
4435
+ * processes sharing `/dev/shm`, so a LOCAL handle resolves to a zero-copy
4436
+ * read here.
4437
+ */
4438
+ frameHandleReaders = null;
4439
+ /**
4429
4440
  * True once the engine + models are fully ready for inference. No
4430
4441
  * longer gates runtime dispatch (Phase 4 removed the legacy `runFrame`
4431
4442
  * which read this flag); kept as a diagnostic the admin UI / tests
@@ -4451,13 +4462,6 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4451
4462
  */
4452
4463
  needsAutoPick = false;
4453
4464
  /**
4454
- * Unsubscribe handle for the deferred-auto-pick `platform-probe` ready
4455
- * listener (armed in `setApi` when the probe isn't ready yet). Cleared once
4456
- * the engine is resolved — either by the listener firing or by the boot
4457
- * safety-net `ensureBootEngineProvisioned`.
4458
- */
4459
- deferredAutoPickUnsub = null;
4460
- /**
4461
4465
  * Warm cache for benchmark engine-override runs.
4462
4466
  *
4463
4467
  * Each override rebuild costs a full Python pool spin-up (~300-500ms)
@@ -4558,108 +4562,71 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4558
4562
  /** Store the addon context. ctx.api is a lazy getter resolved at call time. */
4559
4563
  async setApi(addonCtx) {
4560
4564
  this.addonCtx = addonCtx;
4561
- if (this.needsAutoPick) if (this.addonCtx.useCapability("platform-probe").isReady) {
4562
- await this.autoPickAndPersist();
4563
- this.needsAutoPick = false;
4565
+ if (!this.needsAutoPick) {
4564
4566
  this.startProvisioningForCurrentEngine();
4565
- } else {
4566
- const unsubscribe = this.addonCtx.onCapabilityStateChange("platform-probe", { type: "global" }, (state) => {
4567
- if (state !== "ready") return;
4568
- this.cancelDeferredAutoPick();
4569
- if (!this.needsAutoPick) return;
4570
- this.autoPickAndPersist().then(() => {
4571
- this.needsAutoPick = false;
4572
- this.startProvisioningForCurrentEngine();
4573
- });
4574
- });
4575
- this.deferredAutoPickUnsub = unsubscribe;
4576
- this.addonCtx.addDisposer(() => this.cancelDeferredAutoPick());
4567
+ return;
4577
4568
  }
4578
- else this.startProvisioningForCurrentEngine();
4579
- }
4580
- /** Tear down the deferred-auto-pick probe listener, if still armed. */
4581
- cancelDeferredAutoPick() {
4582
- if (this.deferredAutoPickUnsub) {
4583
- this.deferredAutoPickUnsub();
4584
- this.deferredAutoPickUnsub = null;
4569
+ const nodeId = this.localProbeNodeId();
4570
+ const registry = this.addonCtx.kernel?.readinessRegistry;
4571
+ if (registry) try {
4572
+ await registry.awaitReady("platform-probe", {
4573
+ type: "node",
4574
+ nodeId
4575
+ }, { timeoutMs: PROBE_READY_TIMEOUT_MS });
4576
+ } catch (err) {
4577
+ this.log.warn("platform-probe readiness gate timed out — conservative engine pick (floor is not persisted)", { meta: {
4578
+ nodeId,
4579
+ timeoutMs: PROBE_READY_TIMEOUT_MS,
4580
+ error: require_dist.errMsg(err)
4581
+ } });
4585
4582
  }
4586
- }
4587
- /**
4588
- * Boot safety-net: deterministically provision the hardware-probed engine.
4589
- *
4590
- * Called from the addon's `onInitialize` right after `reprobeEngine` has
4591
- * written this node's probe-driven selection (e.g. `engineBackend=openvino`)
4592
- * to the store. When first-boot auto-pick was DEFERRED (probe not ready at
4593
- * `setApi`), the engine would otherwise stay `idle` — selected but never
4594
- * provisioned — because the `onCapabilityStateChange('platform-probe')` ready
4595
- * edge can be missed when the cap is already 'ready' by the time we subscribe
4596
- * (the old eager-onnx-floor masked this; removing it surfaced a node that
4597
- * boots with no engine at all). This loads the now-persisted selection and
4598
- * starts provisioning it, so the node reliably comes up on its real best
4599
- * engine (openvino on Intel) with no onnx floor and no missed boot. No-op
4600
- * when provisioning already started (persisted-engine path / listener fired)
4601
- * or when nothing has been selected yet.
4602
- */
4603
- async ensureBootEngineProvisioned() {
4604
- if (this.getEngineProvisioning().state !== "idle") return;
4605
- const stored = await this.loadEngine();
4606
- if (!stored) return;
4607
- this.currentEngine = stored;
4583
+ await this.resolveAutoEngine();
4608
4584
  this.needsAutoPick = false;
4609
- this.cancelDeferredAutoPick();
4610
- this.log.info("Boot engine provisioning from probed selection", { meta: {
4611
- runtime: stored.runtime,
4612
- backend: stored.backend,
4613
- device: stored.device ?? null
4614
- } });
4615
4585
  this.startProvisioningForCurrentEngine();
4616
4586
  }
4617
4587
  /**
4618
- * Auto-pick the best supported runtime at first boot (no stored engine).
4619
- * Uses the platform-probe cap's hardware + bestScore hint when available;
4620
- * falls back to platform/arch when the probe cap is not yet reachable.
4588
+ * Resolve the `'auto'` engine LIVE from this node's own kernel inference
4589
+ * resolver (`ctx.kernel.inferenceEngine`) the runtime mirror of
4590
+ * `ctx.kernel.hwaccel`. Each node (hub + forked worker + remote agent)
4591
+ * resolves its OWN accelerators in-process, so a forked agent child never
4592
+ * adopts the hub's runtime from the `platform-probe` singleton.
4621
4593
  *
4622
- * Persists the selection as `engineBackend` + `engineDevice` so subsequent
4623
- * boots load it via `loadEngine()` and skip this path but ONLY when the
4624
- * probe actually answered (real `hardware` or a `bestScore` hint). If the
4625
- * probe query failed (cold-start race, cap momentarily unreachable), the
4626
- * pick floors to onnx purely for lack of information; persisting that would
4627
- * LOCK onnx and skip auto-pick on every future boot even after the
4628
- * accelerator surfaces. In that case we set the in-memory floor for liveness
4629
- * but leave the store untouched so the next boot re-attempts the pick.
4594
+ * NEVER persists. `'auto'` is a live-resolved sentinel: the pick is recomputed
4595
+ * every boot from the local hardware, so it is inherently self-healing the
4596
+ * old `autoPickAndPersist` sticky-onnx bug (a premature onnx pick persisted
4597
+ * during a boot race, then locked forever) cannot recur because the store is
4598
+ * never written here. Only an explicit operator override is persisted, and
4599
+ * that write happens through the settings UI, not this path.
4630
4600
  */
4631
- async autoPickAndPersist() {
4601
+ async resolveAutoEngine() {
4632
4602
  let hardware = null;
4633
4603
  try {
4634
- const api = this.addonCtx?.api;
4635
- if (api) hardware = (await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() }))?.hardware ?? null;
4604
+ hardware = await this.addonCtx?.kernel?.inferenceEngine?.resolveHardware() ?? null;
4636
4605
  } catch {}
4637
- const pick = pickBestRuntime(runtimeEnvFromProcess(toProbedHardware(hardware)), null);
4638
- const engine = {
4606
+ const pick = pickBestRuntime(runtimeEnvFromProcess(hardware ? toProbedHardware(hardware) : null), null);
4607
+ this.currentEngine = {
4639
4608
  runtime: "python",
4640
4609
  backend: pick.runtimeId,
4641
4610
  format: modelFormatFor(pick.runtimeId),
4642
4611
  device: pick.device
4643
4612
  };
4644
- this.currentEngine = engine;
4645
- if (!(pick.runtimeId !== "onnx" || hardware !== null)) {
4646
- this.log.info("Auto-pick: onnx floor pending gpu probe — NOT persisting (re-pick on done)", { meta: {
4647
- backend: pick.runtimeId,
4648
- device: pick.device
4649
- } });
4650
- return;
4651
- }
4652
- const apNode = this.localProbeNodeId();
4653
- await this.writeStore({
4654
- [nodeEngineKey("engineBackend", apNode)]: pick.runtimeId,
4655
- [nodeEngineKey("engineDevice", apNode)]: pick.device
4656
- });
4657
- this.log.info("Auto-picked engine (platform-deterministic)", { meta: {
4613
+ this.log.info("Resolved auto engine (local kernel)", { meta: {
4658
4614
  backend: pick.runtimeId,
4659
4615
  device: pick.device,
4660
- hadProbeHardware: hardware !== null
4616
+ hadHardware: hardware !== null
4661
4617
  } });
4662
4618
  }
4619
+ /**
4620
+ * The RUNTIME that `'auto'` actually resolved to on this node (the live
4621
+ * `currentEngine.backend`), narrowed to a {@link RuntimeId}. The addon feeds
4622
+ * this into `resolveBackendTuning` when the stored config is `'auto'`, so the
4623
+ * pool tuning + respawn detection track the RESOLVED backend rather than the
4624
+ * `'auto'` sentinel (which would otherwise floor to onnx tuning).
4625
+ */
4626
+ resolvedEngineBackend() {
4627
+ const backend = this.currentEngine.backend;
4628
+ return backend === "openvino" || backend === "coreml" ? backend : "onnx";
4629
+ }
4663
4630
  /** Map a backend string to a known RuntimeId, flooring to onnx. */
4664
4631
  toRuntimeId(backend) {
4665
4632
  return [
@@ -4733,6 +4700,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4733
4700
  const prev = this.currentEngine;
4734
4701
  const stored = await this.loadEngine();
4735
4702
  if (stored) this.currentEngine = stored;
4703
+ else await this.resolveAutoEngine();
4736
4704
  if ((prev.runtime !== this.currentEngine.runtime || prev.backend !== this.currentEngine.backend || prev.format !== this.currentEngine.format || (prev.device ?? "") !== (this.currentEngine.device ?? "")) && this.engineFactory) {
4737
4705
  this.log.info("engine selection changed — rebuilding pool in place", { meta: {
4738
4706
  from: `${prev.backend}/${prev.device ?? "default"}`,
@@ -4834,17 +4802,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4834
4802
  }, { ...state }));
4835
4803
  }
4836
4804
  /**
4837
- * Fetch the probed HARDWARE for engine + device gating. Returns null when the
4838
- * probe cap is not yet reachable (cold-start / probe addon not installed), so
4839
- * callers fall back to the registry's safe minimum. The engine OFFER derives
4840
- * from hardware ONLY — install state (probe `scores`) never gates it.
4805
+ * Fetch this node's inference HARDWARE (gpu/npu) for engine + device gating,
4806
+ * from the local kernel resolver. Returns null when the resolver is not wired
4807
+ * yet (cold-start), so callers fall back to the registry's safe minimum. The
4808
+ * engine OFFER derives from hardware ONLY — install state never gates it.
4841
4809
  */
4842
4810
  /**
4843
- * The local Moleculer node id (child-suffix stripped). MUST be passed to every
4844
- * `platformProbe.getCapabilities` query: the cap is a singleton and a query
4845
- * with no nodeId resolves to the HUB's probe — so on a remote agent the engine
4846
- * decision would use the HUB's hardware (e.g. an Intel NPU the agent doesn't
4847
- * have) and pin a device the node can't run, breaking provisioning.
4811
+ * The local Moleculer node id (child-suffix stripped). Used to scope this
4812
+ * node's engine-cascade store keys (`<key>@<node>`). Hardware itself is now
4813
+ * resolved in-process via `ctx.kernel.inferenceEngine` (never the
4814
+ * `platform-probe` singleton, whose no-nodeId query resolved to the HUB and
4815
+ * pinned a device a remote agent couldn't run) so this id is a store-scoping
4816
+ * concern, not a probe-routing one.
4848
4817
  */
4849
4818
  localProbeNodeId() {
4850
4819
  const raw = this.addonCtx?.kernel?.localNodeId ?? "hub";
@@ -4852,9 +4821,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4852
4821
  }
4853
4822
  async fetchProbeGatingData() {
4854
4823
  try {
4855
- const api = this.addonCtx?.api;
4856
- if (!api) return { hardware: null };
4857
- return { hardware: (await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() }))?.hardware ?? null };
4824
+ return { hardware: await this.addonCtx?.kernel?.inferenceEngine?.resolveHardware() ?? null };
4858
4825
  } catch {
4859
4826
  return { hardware: null };
4860
4827
  }
@@ -5194,6 +5161,53 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5194
5161
  } });
5195
5162
  return { success: true };
5196
5163
  }
5164
+ /** This executor's cluster node id (suffix-stripped, matching the runner). */
5165
+ ownNodeId() {
5166
+ const raw = this.addonCtx?.kernel?.localNodeId ?? "hub";
5167
+ return raw.includes("/") ? raw.split("/")[0] : raw;
5168
+ }
5169
+ /**
5170
+ * CB5 — resolve a shm `FrameHandle` to pixels zero-copy. Returns `null`
5171
+ * when:
5172
+ * - the handle is FOREIGN (its segment lives on another node — a local
5173
+ * `shm_open` would flood ENOENT; the runner only sends LOCAL handles, so
5174
+ * this is a defensive guard against an unexpected topology);
5175
+ * - the ring slot was recycled before the read (latest-wins drop) or the
5176
+ * segment could not be mapped.
5177
+ * A `null` degrades the caller to an empty `FrameResult` — the runner ships
5178
+ * pixels via `frame` as the fallback.
5179
+ */
5180
+ resolveFrameHandle(handle) {
5181
+ if (handle.nodeId !== this.ownNodeId()) return null;
5182
+ if (!this.frameHandleReaders) this.frameHandleReaders = new _camstack_shm_ring.FrameRingReaderCache(this.log);
5183
+ const decoded = this.frameHandleReaders.read(handle);
5184
+ if (!decoded) return null;
5185
+ return {
5186
+ data: decoded.data,
5187
+ width: decoded.width,
5188
+ height: decoded.height,
5189
+ format: decoded.format,
5190
+ timestamp: decoded.timestamp
5191
+ };
5192
+ }
5193
+ /**
5194
+ * Minimal detection-free `FrameResult` returned when a `frameHandle` read
5195
+ * misses (recycled slot / foreign / unmappable). Shape-identical to a
5196
+ * zero-detection runtime result so downstream consumers treat it as a
5197
+ * dropped frame, not an error.
5198
+ */
5199
+ emptyFrameHandleResult(input) {
5200
+ return {
5201
+ kind: "frame",
5202
+ frameId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
5203
+ deviceId: input.deviceId ?? 0,
5204
+ timestamp: Date.now(),
5205
+ width: input.frameHandle?.width ?? 0,
5206
+ height: input.frameHandle?.height ?? 0,
5207
+ detections: [],
5208
+ debug: { totalInferenceMs: 0 }
5209
+ };
5210
+ }
5197
5211
  async runPipeline(input, onProgress) {
5198
5212
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
5199
5213
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
@@ -5229,15 +5243,22 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5229
5243
  let jpegProvider;
5230
5244
  const sources = [
5231
5245
  input.frame ? "frame" : null,
5246
+ input.frameHandle ? "frameHandle" : null,
5232
5247
  input.image ? "image" : null,
5233
5248
  input.imageBase64 ? "imageBase64" : null,
5234
5249
  input.referenceImage ? "referenceImage" : null
5235
5250
  ].filter((s) => s !== null);
5236
- if (sources.length === 0) throw new Error("runPipeline requires exactly one of: frame, image, imageBase64, referenceImage");
5251
+ if (sources.length === 0) throw new Error("runPipeline requires exactly one of: frame, frameHandle, image, imageBase64, referenceImage");
5237
5252
  if (sources.length > 1) throw new Error(`runPipeline received conflicting image sources: ${sources.join(", ")}`);
5253
+ let handleFrame = null;
5254
+ if (input.frameHandle) {
5255
+ handleFrame = this.resolveFrameHandle(input.frameHandle);
5256
+ if (!handleFrame) return this.emptyFrameHandleResult(input);
5257
+ }
5258
+ const runtimeFrame = input.frame ?? handleFrame ?? void 0;
5238
5259
  const decodeT0 = performance.now();
5239
- if (input.frame) {
5240
- const frame = input.frame;
5260
+ if (runtimeFrame) {
5261
+ const frame = runtimeFrame;
5241
5262
  imageWidth = frame.width;
5242
5263
  imageHeight = frame.height;
5243
5264
  const data = Buffer.from(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength);
@@ -5431,7 +5452,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5431
5452
  emit("Running inference...");
5432
5453
  const tree = buildExecutableTree(benchmarkSteps, (stepId) => this.engineFactory.getEngine(stepId));
5433
5454
  setupMs = performance.now() - wallT0 - decodeMs;
5434
- const isRuntime = Boolean(input.frame);
5455
+ const isRuntime = Boolean(runtimeFrame);
5435
5456
  const effectiveDeviceId = input.deviceId ?? 0;
5436
5457
  const deviceOverrides = effectiveDeviceId > 0 ? await this.readDeviceStore(effectiveDeviceId) : {};
5437
5458
  const effectiveTree = Object.keys(deviceOverrides).length > 0 ? applyDeviceOverridesToTree(tree, "object-detection", deviceOverrides) : tree;
@@ -6002,6 +6023,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6002
6023
  this.executor = null;
6003
6024
  }
6004
6025
  for (const id of Array.from(this.deviceProxies.keys())) this.releaseDeviceProxy(id);
6026
+ this.frameHandleReaders?.close();
6027
+ this.frameHandleReaders = null;
6005
6028
  }
6006
6029
  /**
6007
6030
  * Resolve and cache a {@link DeviceProxy} for the given camera. Pins
@@ -6184,15 +6207,20 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6184
6207
  * Phase 2b — resolve the engine from the addon's new schema-backed
6185
6208
  * fields (`engineRuntime`, `engineBackend`, `engineDevice`). Fallback
6186
6209
  * to the legacy `KEY_ENGINE` JSON blob for pre-migration stores.
6187
- * Returns null when neither source has anything; the caller keeps
6188
- * the onnx floor set at construction until autoPickAndPersist() runs.
6210
+ *
6211
+ * The `'auto'` sentinel is treated as NO override (returns null) so the
6212
+ * caller re-resolves LIVE via `resolveAutoEngine()` — `'auto'` is the
6213
+ * self-healing default, never a persisted concrete backend. An explicit
6214
+ * concrete backend (onnx/openvino/coreml) is honored as an operator override.
6215
+ * Returns null when neither source has a concrete selection; the caller keeps
6216
+ * the onnx floor set at construction until `resolveAutoEngine()` runs.
6189
6217
  */
6190
6218
  async loadEngine() {
6191
6219
  const store = await this.readStore();
6192
6220
  const storedRuntime = store["engineRuntime"];
6193
6221
  const node = this.localProbeNodeId();
6194
6222
  const storedBackend = readNodeEngineValue(store, "engineBackend", node);
6195
- if (typeof storedBackend === "string" && storedBackend.length > 0) {
6223
+ if (typeof storedBackend === "string" && storedBackend.length > 0 && storedBackend !== "auto") {
6196
6224
  const backend = storedBackend;
6197
6225
  const storedDeviceRaw = readNodeEngineValue(store, "engineDevice", node);
6198
6226
  const storedDevice = typeof storedDeviceRaw === "string" ? storedDeviceRaw : "";
@@ -6346,12 +6374,11 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6346
6374
  return { success: true };
6347
6375
  }
6348
6376
  async reprobeEngine() {
6349
- const api = this.addonCtx?.api;
6350
6377
  let hardware = null;
6351
- if (api) try {
6352
- hardware = (await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() }))?.hardware ?? null;
6378
+ try {
6379
+ hardware = await this.addonCtx?.kernel?.inferenceEngine?.resolveHardware() ?? null;
6353
6380
  } catch {}
6354
- const pick = pickBestRuntime(runtimeEnvFromProcess(toProbedHardware(hardware)), null);
6381
+ const pick = pickBestRuntime(runtimeEnvFromProcess(hardware ? toProbedHardware(hardware) : null), null);
6355
6382
  const best = {
6356
6383
  runtime: "python",
6357
6384
  backend: pick.runtimeId,
@@ -6361,47 +6388,18 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6361
6388
  const probedLabel = `${best.backend}/${best.device ?? "default"}`;
6362
6389
  const rpNode = this.localProbeNodeId();
6363
6390
  if (pick.runtimeId !== "onnx" || hardware !== null) {
6364
- await this.writeStore({
6365
- [nodeEngineKey("probedBestEngine", rpNode)]: probedLabel,
6366
- [nodeEngineKey("engineBackend", rpNode)]: best.backend,
6367
- [nodeEngineKey("engineDevice", rpNode)]: best.device ?? "cpu"
6368
- });
6369
- this.log.info("Re-probed engine (platform-deterministic) — wrote back", { meta: {
6391
+ await this.writeStore({ [nodeEngineKey("probedBestEngine", rpNode)]: probedLabel });
6392
+ this.log.info("Re-probed engine (local kernel) — updated probedBestEngine hint", { meta: {
6370
6393
  backend: best.backend,
6371
6394
  device: best.device ?? null,
6372
6395
  probedBestEngine: probedLabel
6373
6396
  } });
6374
- } else this.log.info("Re-probe: onnx floor pending gpu probe — NOT persisting (re-pick on done)", { meta: {
6397
+ } else this.log.info("Re-probe: onnx floor pending gpu probe — hint NOT persisted", { meta: {
6375
6398
  backend: best.backend,
6376
6399
  device: best.device ?? null
6377
6400
  } });
6378
6401
  return best;
6379
6402
  }
6380
- /**
6381
- * Re-pick the engine when the platform-probe finishes its async hardware +
6382
- * Python detection (the `platform-probe.phase` `done` event). At boot the
6383
- * probe's accelerator result may not be ready yet, so the engine floored to
6384
- * onnx; once the probe answers (e.g. a Mac's CoreML/ANE surfaces after the
6385
- * embedded Python is installed) this re-runs the probe-driven pick and
6386
- * re-provisions. Idempotent: `startProvisioningForCurrentEngine` skips a
6387
- * no-op when the selection is unchanged.
6388
- */
6389
- async repickEngineOnProbeReady() {
6390
- const before = `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`;
6391
- await this.reprobeEngine();
6392
- const stored = await this.loadEngine();
6393
- if (stored) {
6394
- this.currentEngine = stored;
6395
- this.needsAutoPick = false;
6396
- this.cancelDeferredAutoPick();
6397
- }
6398
- const after = `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`;
6399
- if (before !== after) this.log.info("Engine re-picked after platform-probe completed", { meta: {
6400
- before,
6401
- after
6402
- } });
6403
- this.startProvisioningForCurrentEngine();
6404
- }
6405
6403
  async getReferenceAudioFiles() {
6406
6404
  const dir = resolveReferenceAudioDir();
6407
6405
  if (!dir) return [];
@@ -6813,7 +6811,7 @@ var DEFAULT_CONFIG = {
6813
6811
  intraOpThreads: 0,
6814
6812
  numWorkers: 0,
6815
6813
  engineRuntime: "python",
6816
- engineBackend: "onnx",
6814
+ engineBackend: "auto",
6817
6815
  engineDevice: "cpu",
6818
6816
  probedBestEngine: "",
6819
6817
  activeEngine: ""
@@ -6843,6 +6841,15 @@ function stableEngineKey(engines) {
6843
6841
  return JSON.stringify(stripped);
6844
6842
  }
6845
6843
  /**
6844
+ * Narrow a topology-reported platform string to the runtime-relevant
6845
+ * `NodeJS.Platform` values the engine cascade branches on (darwin/linux/win32).
6846
+ * A type guard, not a cast; any other value → undefined so the caller floors to
6847
+ * `process.platform`. These three match `HardwarePlatformSchema` in the cap.
6848
+ */
6849
+ function toNodePlatform(value) {
6850
+ return value === "darwin" || value === "linux" || value === "win32" ? value : void 0;
6851
+ }
6852
+ /**
6846
6853
  * Tuning fields that bind into the Python pool at construction. Any
6847
6854
  * change requires a pool respawn to take effect (the executor caches
6848
6855
  * `tuning` at engine-factory build time). `onConfigChanged` watches
@@ -6857,7 +6864,7 @@ var POOL_BOUND_KEYS = [
6857
6864
  "intraOpThreads",
6858
6865
  "numWorkers"
6859
6866
  ];
6860
- var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6867
+ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.BaseAddon {
6861
6868
  provider = null;
6862
6869
  /** Last non-null probed hardware PER NODE — reused when the probe transiently
6863
6870
  * returns null so offered backends / device lists don't collapse. Keyed by
@@ -6865,13 +6872,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6865
6872
  * Carries the probed node's platform/arch too, so a transient null probe
6866
6873
  * doesn't flip a remote node's env back to the serving hub's platform. */
6867
6874
  lastGoodHardwareByNode = /* @__PURE__ */ new Map();
6868
- /** This node's effective engine selection, cached from the node-scoped store
6869
- * so the synchronous `resolveBackendTuning` and the reprobe gate don't read
6870
- * the cluster-shared bare keys. Refreshed at init + on every config change. */
6871
- nodeEngineBackend = DEFAULT_CONFIG.engineBackend;
6872
- nodeProbedBestEngine = "";
6873
6875
  engineMetricsTimer = null;
6874
- probePhaseUnsub = null;
6875
6876
  /** Snapshot-equality cache for engine-metrics emit. Most ticks
6876
6877
  * the engine inventory is unchanged (no model load/unload), so
6877
6878
  * we skip the bus emit and let the heartbeat re-emit at
@@ -6919,6 +6920,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6919
6920
  description: "Auto-detected best engine for this host (format: provider/device, e.g. \"openvino/npu\"). Click the refresh icon to re-run the probe — the detected provider and hardware device are written back into the two fields below, overwriting any manual override.",
6920
6921
  readonlyField: true,
6921
6922
  default: "",
6923
+ perNode: true,
6922
6924
  actions: [{
6923
6925
  action: "reprobe-engine",
6924
6926
  icon: "refresh-cw",
@@ -6937,9 +6939,13 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6937
6939
  type: "select",
6938
6940
  key: "engineBackend",
6939
6941
  label: "Execution provider",
6940
- options: [...STATIC_BACKEND_OPTIONS],
6942
+ options: [{
6943
+ value: "auto",
6944
+ label: "Auto (best for this node)"
6945
+ }, ...STATIC_BACKEND_OPTIONS],
6941
6946
  default: DEFAULT_CONFIG.engineBackend,
6942
- immediate: true
6947
+ immediate: true,
6948
+ perNode: true
6943
6949
  }),
6944
6950
  this.field({
6945
6951
  type: "select",
@@ -6947,7 +6953,8 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6947
6953
  label: "Hardware device",
6948
6954
  options: [...STATIC_DEFAULT_DEVICE_OPTIONS],
6949
6955
  default: DEFAULT_CONFIG.engineDevice,
6950
- immediate: true
6956
+ immediate: true,
6957
+ perNode: true
6951
6958
  })
6952
6959
  ]
6953
6960
  }, {
@@ -7076,12 +7083,17 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7076
7083
  const env = await this.probeHardwareEnv(targetNode);
7077
7084
  const hardware = env.hardware;
7078
7085
  const offered = supportedRuntimes(env);
7079
- const runtimeBackends = offered.map((id) => ({
7086
+ const runtimeBackends = [{
7087
+ value: "auto",
7088
+ label: "Auto (best for this node)"
7089
+ }, ...offered.map((id) => ({
7080
7090
  value: id,
7081
7091
  label: runtimeLabel(id)
7082
- }));
7092
+ }))];
7083
7093
  const storedBackend = typeof merged.engineBackend === "string" ? merged.engineBackend : "";
7094
+ const isAuto = storedBackend === "auto";
7084
7095
  const backend = (() => {
7096
+ if (isAuto) return pickBestRuntime(env, null).runtimeId;
7085
7097
  const rid = toRuntimeId(storedBackend);
7086
7098
  if (rid === "onnx") return "onnx";
7087
7099
  if (offered.includes(rid)) return rid;
@@ -7092,9 +7104,10 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7092
7104
  const device = deviceOptions.find((d) => d.value === storedDevice)?.value ?? defaultDeviceFor(backend);
7093
7105
  const prov = targetNode === this.localNodeId() ? this.provider?.getEngineProvisioning() : void 0;
7094
7106
  const activeEngine = prov && prov.runtimeId ? `${prov.runtimeId}/${prov.device ?? "default"} (${prov.state})` : "";
7107
+ const displayBackend = isAuto ? "auto" : backend;
7095
7108
  const raw = {
7096
7109
  ...merged,
7097
- engineBackend: backend,
7110
+ engineBackend: displayBackend,
7098
7111
  engineDevice: device,
7099
7112
  activeEngine
7100
7113
  };
@@ -7170,14 +7183,37 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7170
7183
  const hardware = await this.resolveProbeHardware(node);
7171
7184
  if (hardware) this.lastGoodHardwareByNode.set(node, hardware);
7172
7185
  const effective = hardware ?? this.lastGoodHardwareByNode.get(node) ?? null;
7173
- const useProbedPlatform = node !== this.localNodeId();
7186
+ const isRemote = node !== this.localNodeId();
7187
+ const topo = isRemote ? await this.resolveTopologyPlatform(node) : null;
7174
7188
  return {
7175
- platform: useProbedPlatform && effective?.platform !== void 0 ? effective.platform : process.platform,
7176
- arch: useProbedPlatform && effective?.arch !== void 0 ? effective.arch : process.arch,
7189
+ platform: (isRemote ? topo?.platform ?? effective?.platform : void 0) ?? process.platform,
7190
+ arch: (isRemote ? topo?.arch ?? effective?.arch : void 0) ?? process.arch,
7177
7191
  hardware: effective
7178
7192
  };
7179
7193
  }
7180
7194
  /**
7195
+ * Read the TARGET node's platform/arch from cluster topology — the
7196
+ * registration self-report (`nodes.topology`), which is hub-local and
7197
+ * available immediately, independent of the node's async accelerator probe.
7198
+ * Mirrors the audio analyzer's `resolveNodePlatform` (the D6 reference
7199
+ * pattern) so detection + audio share ONE topology-read path. Returns null
7200
+ * when topology is unreachable (caller falls back to the probe report, then
7201
+ * `process.platform`). Narrows the reported platform to the runtime-relevant
7202
+ * `NodeJS.Platform` values via a type guard (no cast); any other value is
7203
+ * dropped so the caller floors safely.
7204
+ */
7205
+ async resolveTopologyPlatform(nodeId) {
7206
+ try {
7207
+ const picked = require_node_topology_platform.pickNodePlatformArch(await this.ctxIfReady?.api?.nodes?.topology.query(), nodeId);
7208
+ return {
7209
+ platform: toNodePlatform(picked.platform),
7210
+ arch: picked.arch
7211
+ };
7212
+ } catch {
7213
+ return null;
7214
+ }
7215
+ }
7216
+ /**
7181
7217
  * Fetch the probed hardware from the platform-probe cap for `nodeId` (default
7182
7218
  * = self). Returns null when the cap is not reachable (caller falls back to
7183
7219
  * the registry's safe minimum). Carries the probed node's `platform`/`arch`
@@ -7189,7 +7225,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7189
7225
  const api = this.ctxIfReady?.api;
7190
7226
  if (!api) return null;
7191
7227
  const node = nodeId ?? this.localNodeId();
7192
- const hw = (node === this.localNodeId() ? await api.platformProbe.getCapabilities.query() : await api.platformProbe.getCapabilities.query(void 0, require_dist.nodePin(node)))?.hardware;
7228
+ const hw = (node === this.localNodeId() ? await api.platformProbe.getCapabilities.query() : await api.platformProbe.getCapabilities.query({ nodeId: node }))?.hardware;
7193
7229
  if (!hw) return null;
7194
7230
  return {
7195
7231
  npu: hw.npu ? { type: hw.npu.type } : null,
@@ -7214,81 +7250,6 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7214
7250
  return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
7215
7251
  }
7216
7252
  /**
7217
- * One-time store migration (hub only, F1/CB3): the pre-scoping store
7218
- * persisted the engine cascade under BARE keys (`engineBackend`, …) that
7219
- * only the hub ever wrote. `readNodeEngineValue` used to serve those bare
7220
- * values as a fallback to EVERY node, leaking the hub's engine (e.g.
7221
- * `openvino/auto`) onto agents with no scoped key. The fallback is now
7222
- * hub-only (engine-store-keys.ts) and this migration re-homes the bare
7223
- * values to their `<key>@hub` scoped form and deletes the bare keys so the
7224
- * legacy values cannot resurface. Guarded: runs only while a bare key is
7225
- * still present, so post-migration boots are a no-op. Best-effort — an
7226
- * unreadable store skips the migration (retried on the next boot).
7227
- */
7228
- async migrateLegacyBareEngineKeys() {
7229
- if (this.localNodeId() !== "hub") return;
7230
- const store = await this.resolveUiSettingsStore();
7231
- if (store === null) return;
7232
- const patch = {};
7233
- for (const base of ENGINE_CASCADE_KEYS) {
7234
- const bare = store[base];
7235
- if (bare === void 0 || bare === null) continue;
7236
- const scopedKey = nodeEngineKey(base, "hub");
7237
- if (store[scopedKey] === void 0) patch[scopedKey] = bare;
7238
- patch[base] = void 0;
7239
- }
7240
- if (Object.keys(patch).length === 0) return;
7241
- await this.ctxIfReady?.settings?.writeAddonStore(patch);
7242
- this.ctx.logger.info("Migrated legacy bare engine keys to their @hub scoped form", { meta: { keys: Object.keys(patch) } });
7243
- }
7244
- /**
7245
- * Refresh this node's cached engine selection from the node-scoped store.
7246
- * `resolveBackendTuning` is synchronous and the reprobe gate runs before the
7247
- * provider exists, so both read these cached fields instead of the
7248
- * cluster-shared bare keys (which belong to no single node). Best-effort: a
7249
- * transiently-unreadable store leaves the last cached values in place.
7250
- */
7251
- async refreshNodeEngineFromStore() {
7252
- const store = await this.resolveUiSettingsStore();
7253
- if (store === null) return;
7254
- const node = this.localNodeId();
7255
- const backend = readNodeEngineValue(store, "engineBackend", node);
7256
- if (typeof backend === "string" && backend !== "") this.nodeEngineBackend = backend;
7257
- const probed = readNodeEngineValue(store, "probedBestEngine", node);
7258
- this.nodeProbedBestEngine = typeof probed === "string" ? probed : "";
7259
- }
7260
- /**
7261
- * Persist a settings patch, mirroring the engine cascade fields to the TARGET
7262
- * node's scoped keys so each node keeps an INDEPENDENT engine selection in the
7263
- * cluster-central store. The hub addon serves writes for every node, so it
7264
- * scopes by the requested `nodeId` (default self).
7265
- *
7266
- * The engine cascade keys are NEVER written bare (F1, CB3): the bare keys are
7267
- * the legacy pre-scoping store whose values used to leak to every node with
7268
- * no scoped key. When the target IS this node, `super.updateGlobalSettings`
7269
- * drives the normal apply path (`resolveConfig` / `onConfigChanged` /
7270
- * `requiresRestart` restart) with the engine keys STRIPPED from its patch —
7271
- * they were already persisted node-scoped, and `onConfigChanged` re-reads
7272
- * them from the scoped keys. When the target is a SIBLING node, one scoped
7273
- * write persists the engine keys under `<key>@<target>` + the non-engine
7274
- * keys bare, and this node's restart/reprovision is NOT run — the owning
7275
- * node applies its own engine selection on its next (re)start.
7276
- */
7277
- async updateGlobalSettings(patch, nodeId) {
7278
- const targetNode = nodeId ? normalizeEngineNodeId(nodeId) : this.localNodeId();
7279
- const patchRecord = patch;
7280
- if (targetNode !== this.localNodeId()) {
7281
- const scoped = scopeEnginePatch(patchRecord, targetNode);
7282
- if (Object.keys(scoped).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(scoped);
7283
- return;
7284
- }
7285
- const scopedEngine = {};
7286
- for (const key of ENGINE_CASCADE_KEYS) if (key in patchRecord) scopedEngine[nodeEngineKey(key, targetNode)] = patchRecord[key];
7287
- if (Object.keys(scopedEngine).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(scopedEngine);
7288
- const { engineBackend: _engineBackend, engineDevice: _engineDevice, probedBestEngine: _probedBestEngine, ...sharedPatch } = patch;
7289
- await super.updateGlobalSettings(sharedPatch, nodeId);
7290
- }
7291
- /**
7292
7253
  * Resolve the effective pool tuning for the configured backend.
7293
7254
  *
7294
7255
  * Reads the registry's `tuningFor(backend)` and ignores any persisted
@@ -7302,7 +7263,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7302
7263
  * a reason to disagree.
7303
7264
  */
7304
7265
  resolveBackendTuning() {
7305
- const t = tuningFor(toRuntimeId(this.nodeEngineBackend ?? DEFAULT_CONFIG.engineBackend));
7266
+ const t = tuningFor(this.config.engineBackend === "auto" ? this.provider?.resolvedEngineBackend() ?? "onnx" : toRuntimeId(this.config.engineBackend));
7306
7267
  const num = (v, dflt) => typeof v === "number" && v > 0 ? v : dflt;
7307
7268
  const batch = (v, dflt) => v === "none" || v === "list" || v === "window" ? v : dflt;
7308
7269
  return {
@@ -7315,44 +7276,59 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7315
7276
  numWorkers: num(t["numWorkers"], 1)
7316
7277
  };
7317
7278
  }
7279
+ /**
7280
+ * Construct a fresh provider with the given effective tuning. Single source of
7281
+ * truth for the provider wiring — shared by `onInitialize`, the post-resolve
7282
+ * tuning reconciliation, and the `onConfigChanged` pool respawn.
7283
+ */
7284
+ buildProvider(modelsDir, tuning) {
7285
+ if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7286
+ return new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
7287
+ concurrency: tuning.concurrency,
7288
+ tuning: {
7289
+ batchMode: tuning.batchMode,
7290
+ windowMs: tuning.windowMs,
7291
+ maxBatchSize: tuning.maxBatchSize,
7292
+ numStreams: tuning.numStreams,
7293
+ intraOpThreads: tuning.intraOpThreads
7294
+ },
7295
+ numWorkers: tuning.numWorkers,
7296
+ pythonPath: this.pythonPath,
7297
+ pythonAddonDir: this.pythonAddonDir
7298
+ });
7299
+ }
7300
+ /**
7301
+ * Pool-bound tuning keys of `a` and `b` are equal (used to decide whether a
7302
+ * post-resolve / config-change respawn is actually needed).
7303
+ */
7304
+ static poolTuningEqual(a, b) {
7305
+ return a.concurrency === b.concurrency && a.batchMode === b.batchMode && a.windowMs === b.windowMs && a.maxBatchSize === b.maxBatchSize && a.numStreams === b.numStreams && a.intraOpThreads === b.intraOpThreads && a.numWorkers === b.numWorkers;
7306
+ }
7318
7307
  async onInitialize() {
7319
7308
  const modelsDir = await this.resolveModelsDir();
7320
7309
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7321
- await this.migrateLegacyBareEngineKeys();
7322
- await this.refreshNodeEngineFromStore();
7323
7310
  this.pythonAddonDir = resolveAddonPythonDir();
7324
7311
  const py = await ensurePythonReady(this.ctx.deps, this.ctx.logger);
7325
7312
  if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
7326
7313
  else this.ctx.logger.warn("Detection engine boot continues without Python — selection will provision on demand", {});
7327
7314
  await this.proactivelyInstallOpenvino();
7328
- const effectiveTuning = this.resolveBackendTuning();
7329
- this.provider = new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
7330
- concurrency: effectiveTuning.concurrency,
7331
- tuning: {
7332
- batchMode: effectiveTuning.batchMode,
7333
- windowMs: effectiveTuning.windowMs,
7334
- maxBatchSize: effectiveTuning.maxBatchSize,
7335
- numStreams: effectiveTuning.numStreams,
7336
- intraOpThreads: effectiveTuning.intraOpThreads
7337
- },
7338
- numWorkers: effectiveTuning.numWorkers,
7339
- pythonPath: this.pythonPath,
7340
- pythonAddonDir: this.pythonAddonDir
7341
- });
7315
+ const initialTuning = this.resolveBackendTuning();
7316
+ this.provider = this.buildProvider(modelsDir, initialTuning);
7342
7317
  await this.provider.init();
7343
7318
  await this.provider.setApi(this.ctx);
7344
- if (!this.nodeProbedBestEngine) await this.provider.reprobeEngine().catch((err) => {
7319
+ const resolvedTuning = this.resolveBackendTuning();
7320
+ if (!DetectionPipelineAddon.poolTuningEqual(initialTuning, resolvedTuning)) {
7321
+ this.ctx.logger.info("detection-pipeline tuning reconciled to resolved engine", { meta: {
7322
+ from: initialTuning,
7323
+ to: resolvedTuning
7324
+ } });
7325
+ this.provider = this.buildProvider(modelsDir, resolvedTuning);
7326
+ await this.provider.init();
7327
+ await this.provider.setApi(this.ctx);
7328
+ }
7329
+ if (!this.config.probedBestEngine) await this.provider.reprobeEngine().catch((err) => {
7345
7330
  this.ctx.logger.warn("auto-reprobe engine failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7346
7331
  });
7347
- await this.provider.ensureBootEngineProvisioned().catch((err) => {
7348
- this.ctx.logger.warn("ensureBootEngineProvisioned failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7349
- });
7350
- this.probePhaseUnsub = this.ctx.eventBus?.subscribe({ category: require_dist.EventCategory.PlatformProbePhase }, (event) => {
7351
- if (event.data?.phase !== "done") return;
7352
- this.provider?.repickEngineOnProbeReady().catch((err) => {
7353
- this.ctx.logger.warn("repick on platform-probe done failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7354
- });
7355
- }) ?? null;
7356
7332
  await this.provider.warmPool();
7357
7333
  this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
7358
7334
  this.lastAppliedPoolConfig = this.snapshotPoolConfig();
@@ -7406,13 +7382,18 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7406
7382
  * first loaded (gated by `loadEngine`'s `isPythonBackendAvailable` check).
7407
7383
  *
7408
7384
  * Failure is non-fatal: a warning is logged and the addon continues with
7409
- * the onnx-cpu baseline. The hardware query itself is also best-effort —
7410
- * if the platform-probe cap isn't wired yet the error is caught here.
7385
+ * the onnx-cpu baseline. The hardware resolve itself is also best-effort —
7386
+ * if the kernel resolver isn't wired yet the error is caught here.
7411
7387
  */
7412
7388
  async proactivelyInstallOpenvino() {
7413
7389
  if (!this.pythonAddonDir || !this.pythonPath) return;
7414
7390
  try {
7415
- const hardware = await this.ctx.api.platformProbe.getHardware.query();
7391
+ const hw = await this.ctx.kernel?.inferenceEngine?.resolveHardware();
7392
+ const hardware = {
7393
+ platform: process.platform,
7394
+ gpu: hw?.gpu ?? null,
7395
+ npu: hw?.npu ?? null
7396
+ };
7416
7397
  if (!shouldInstallOpenvino(hardware)) return;
7417
7398
  this.ctx.logger.info("Intel hardware detected — proactively installing OpenVINO Python package", { meta: {
7418
7399
  gpu: hardware.gpu?.type ?? null,
@@ -7426,10 +7407,6 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7426
7407
  }
7427
7408
  }
7428
7409
  async onShutdown() {
7429
- if (this.probePhaseUnsub) {
7430
- this.probePhaseUnsub();
7431
- this.probePhaseUnsub = null;
7432
- }
7433
7410
  if (this.engineMetricsTimer) {
7434
7411
  clearInterval(this.engineMetricsTimer);
7435
7412
  this.engineMetricsTimer = null;
@@ -7477,7 +7454,6 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7477
7454
  * re-spin, frames are dropped (never crashed), no addon bounce.
7478
7455
  */
7479
7456
  async onConfigChanged() {
7480
- await this.refreshNodeEngineFromStore();
7481
7457
  if (this.provider) await this.provider.onEngineSelectionChanged().catch((err) => {
7482
7458
  this.ctx.logger.warn("engine provisioning re-select failed on config change", { meta: { error: err instanceof Error ? err.message : String(err) } });
7483
7459
  });
@@ -7496,21 +7472,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7496
7472
  this.ctx.logger.warn("provider shutdown failed during tuning respawn", { meta: { error: err instanceof Error ? err.message : String(err) } });
7497
7473
  }
7498
7474
  const modelsDir = await this.resolveModelsDir();
7499
- if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available during respawn");
7500
- const effectiveTuning = this.resolveBackendTuning();
7501
- this.provider = new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {
7502
- concurrency: effectiveTuning.concurrency,
7503
- tuning: {
7504
- batchMode: effectiveTuning.batchMode,
7505
- windowMs: effectiveTuning.windowMs,
7506
- maxBatchSize: effectiveTuning.maxBatchSize,
7507
- numStreams: effectiveTuning.numStreams,
7508
- intraOpThreads: effectiveTuning.intraOpThreads
7509
- },
7510
- numWorkers: effectiveTuning.numWorkers,
7511
- pythonPath: this.pythonPath,
7512
- pythonAddonDir: this.pythonAddonDir
7513
- });
7475
+ this.provider = this.buildProvider(modelsDir, this.resolveBackendTuning());
7514
7476
  await this.provider.init();
7515
7477
  await this.provider.setApi(this.ctx);
7516
7478
  await this.provider.warmPool();