@camstack/addon-pipeline 1.1.27 → 1.1.28

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 (43) 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 -1
  4. package/dist/audio-codec-ffmpeg/index.mjs +1 -1
  5. package/dist/decoder-ffmpeg/index.js +43 -11
  6. package/dist/decoder-ffmpeg/index.mjs +43 -11
  7. package/dist/decoder-nodeav/index.js +13 -8
  8. package/dist/decoder-nodeav/index.mjs +13 -8
  9. package/dist/detection-pipeline/index.js +157 -219
  10. package/dist/detection-pipeline/index.mjs +157 -219
  11. package/dist/{dist-DAIlCdAx.js → dist-Biq62zt4.js} +372 -35
  12. package/dist/{dist-CgEP_0OL.mjs → dist-C6_wgXqF.mjs} +373 -24
  13. package/dist/{frame-handle-plane-Dq20KtKL.mjs → frame-handle-plane-BIoY6nRV.mjs} +13 -5
  14. package/dist/{frame-handle-plane-DtTRX_0n.js → frame-handle-plane-D6BzyEgy.js} +13 -5
  15. package/dist/{frame-ring-sink-BbzrqTWj.js → frame-ring-sink-9J0wCdLF.js} +128 -23
  16. package/dist/{frame-ring-sink-B_NvPTJZ.mjs → frame-ring-sink-Cs9vby6v.mjs} +117 -24
  17. package/dist/motion-wasm/index.js +55 -3
  18. package/dist/motion-wasm/index.mjs +55 -3
  19. package/dist/node-topology-platform-BkR_k6WT.mjs +15 -0
  20. package/dist/node-topology-platform-CFZ7F4xW.js +20 -0
  21. package/dist/pipeline-runner/index.js +50 -16
  22. package/dist/pipeline-runner/index.mjs +50 -16
  23. package/dist/recorder/index.js +36 -40
  24. package/dist/recorder/index.mjs +36 -40
  25. package/dist/stream-broker/_stub.js +2 -2
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-5tQlh9h4.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DoReAb4y.mjs} +3 -3
  27. 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
  28. 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
  29. package/dist/stream-broker/{hostInit-DyLqyJaS.mjs → hostInit-C0SuwQhL.mjs} +3 -3
  30. package/dist/stream-broker/index.js +61 -25
  31. package/dist/stream-broker/index.mjs +61 -25
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js → MaskShapeCanvas-DI4BY7W2-Br2yi8ah.js} +1 -1
  34. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CoLjNiUN.js → MotionZonesSettings-NcxxQN8r-BSCcPdPf.js} +1 -1
  35. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js → PrivacyMaskSettings-APgPLF7p-ztxvxSjS.js} +1 -1
  36. package/embed-dist/assets/index-C5UpuPr8.css +2 -0
  37. package/embed-dist/assets/{index-C-pL8ETk.js → index-CeZV1B-2.js} +10 -10
  38. package/embed-dist/index.html +2 -2
  39. package/package.json +1 -1
  40. package/python/inference_pool.py +93 -102
  41. package/python/test_inference_pool_device_selection.py +43 -38
  42. 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
  43. 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-Biq62zt4.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");
@@ -27,13 +29,15 @@ sharp = require_model_download_service_C_IHWnXx.__toESM(sharp);
27
29
  * another node that has no NPU.
28
30
  *
29
31
  * 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.
32
+ * Everything else in the store stays shared. There is NO fallback to a legacy
33
+ * un-scoped value for ANY node (the hub included) and no in-code migration: a
34
+ * bare key (written before scoping, or by an older build) is inert invisible
35
+ * to every node. A node with no scoped key reads `undefined`, which drives its
36
+ * OWN platform auto-pick (`autoPickAndPersist` / `reprobeEngine` persist the
37
+ * node's own probe under `<key>@<node>`). Serving a bare value to any node
38
+ * would leak one node's hardware pick (e.g. `openvino/auto`) onto a node that
39
+ * cannot run it (the CB3 "Mac runs OpenVINO" / N100 AUTO-device bug). This
40
+ * mirrors the decoder's `readNodeDecoderBackend` no-fallback rule.
37
41
  */
38
42
  var ENGINE_CASCADE_KEYS = [
39
43
  "engineBackend",
@@ -58,27 +62,14 @@ function nodeEngineKey(base, nodeId) {
58
62
  }
59
63
  /**
60
64
  * 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.
65
+ * otherwise `undefined` — for EVERY node, the hub included. There is no
66
+ * fallback to the legacy un-scoped key: a bare value in the store is inert.
67
+ * A node with no scoped key gets `undefined` so its own platform-driven
68
+ * auto-pick runs and persists a real per-node selection under `<key>@<node>`.
69
+ * (A bare fallback would leak one node's engine selection cluster-wide CB3.)
67
70
  */
68
71
  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;
72
+ return store[nodeEngineKey(base, nodeId)];
82
73
  }
83
74
  /**
84
75
  * Project a raw store onto the plain engine cascade keys for THIS node, so the
@@ -4348,6 +4339,15 @@ var ONNX_FLOOR = {
4348
4339
  device: "cpu"
4349
4340
  };
4350
4341
  /**
4342
+ * How long the first-boot pick waits for the node's `platform-probe` to flip
4343
+ * `ready` (= its FULL async probe done, embedded-Python install included).
4344
+ * MUST stay finite — `readinessRegistry.awaitReady` defaults to Infinity,
4345
+ * which would wedge `setApi` (and the addon's onInitialize) forever on a
4346
+ * node whose probe never completes. On timeout the pick proceeds
4347
+ * conservatively (platform floor, never persisted when non-authoritative).
4348
+ */
4349
+ var PROBE_READY_TIMEOUT_MS = 12e4;
4350
+ /**
4351
4351
  * Build the onnx-cpu floor pick using `pickBestRuntime` with a null hardware
4352
4352
  * env. Used wherever the old `detectBestEngine()` sync probe fell back — the
4353
4353
  * result is identical (onnx / cpu) but is now derived through the shared rules
@@ -4426,6 +4426,16 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4426
4426
  */
4427
4427
  lastStepsSignature = null;
4428
4428
  /**
4429
+ * CB5 shm passthrough — the process-local, LONG-LIVED reader cache for
4430
+ * `runPipeline({ frameHandle })`. Opens each named shm segment ONCE and
4431
+ * reuses the mapping for every later handle on the same ring (never
4432
+ * open/close per-call). Lazily created on first handle, unmapped in
4433
+ * {@link shutdown}. The executor and the runner are both hub-local
4434
+ * processes sharing `/dev/shm`, so a LOCAL handle resolves to a zero-copy
4435
+ * read here.
4436
+ */
4437
+ frameHandleReaders = null;
4438
+ /**
4429
4439
  * True once the engine + models are fully ready for inference. No
4430
4440
  * longer gates runtime dispatch (Phase 4 removed the legacy `runFrame`
4431
4441
  * which read this flag); kept as a diagnostic the admin UI / tests
@@ -4451,13 +4461,6 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4451
4461
  */
4452
4462
  needsAutoPick = false;
4453
4463
  /**
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
4464
  * Warm cache for benchmark engine-override runs.
4462
4465
  *
4463
4466
  * Each override rebuild costs a full Python pool spin-up (~300-500ms)
@@ -4558,60 +4561,26 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4558
4561
  /** Store the addon context. ctx.api is a lazy getter resolved at call time. */
4559
4562
  async setApi(addonCtx) {
4560
4563
  this.addonCtx = addonCtx;
4561
- if (this.needsAutoPick) if (this.addonCtx.useCapability("platform-probe").isReady) {
4562
- await this.autoPickAndPersist();
4563
- this.needsAutoPick = false;
4564
+ if (!this.needsAutoPick) {
4564
4565
  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());
4566
+ return;
4577
4567
  }
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;
4568
+ const nodeId = this.localProbeNodeId();
4569
+ const registry = this.addonCtx.kernel?.readinessRegistry;
4570
+ if (registry) try {
4571
+ await registry.awaitReady("platform-probe", {
4572
+ type: "node",
4573
+ nodeId
4574
+ }, { timeoutMs: PROBE_READY_TIMEOUT_MS });
4575
+ } catch (err) {
4576
+ this.log.warn("platform-probe readiness gate timed out — conservative engine pick (floor is not persisted)", { meta: {
4577
+ nodeId,
4578
+ timeoutMs: PROBE_READY_TIMEOUT_MS,
4579
+ error: require_dist.errMsg(err)
4580
+ } });
4585
4581
  }
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;
4582
+ await this.autoPickAndPersist();
4608
4583
  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
4584
  this.startProvisioningForCurrentEngine();
4616
4585
  }
4617
4586
  /**
@@ -4643,7 +4612,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4643
4612
  };
4644
4613
  this.currentEngine = engine;
4645
4614
  if (!(pick.runtimeId !== "onnx" || hardware !== null)) {
4646
- this.log.info("Auto-pick: onnx floor pending gpu probe — NOT persisting (re-pick on done)", { meta: {
4615
+ this.log.info("Auto-pick: onnx floor pending gpu probe — NOT persisting (next boot retries)", { meta: {
4647
4616
  backend: pick.runtimeId,
4648
4617
  device: pick.device
4649
4618
  } });
@@ -5194,6 +5163,53 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5194
5163
  } });
5195
5164
  return { success: true };
5196
5165
  }
5166
+ /** This executor's cluster node id (suffix-stripped, matching the runner). */
5167
+ ownNodeId() {
5168
+ const raw = this.addonCtx?.kernel?.localNodeId ?? "hub";
5169
+ return raw.includes("/") ? raw.split("/")[0] : raw;
5170
+ }
5171
+ /**
5172
+ * CB5 — resolve a shm `FrameHandle` to pixels zero-copy. Returns `null`
5173
+ * when:
5174
+ * - the handle is FOREIGN (its segment lives on another node — a local
5175
+ * `shm_open` would flood ENOENT; the runner only sends LOCAL handles, so
5176
+ * this is a defensive guard against an unexpected topology);
5177
+ * - the ring slot was recycled before the read (latest-wins drop) or the
5178
+ * segment could not be mapped.
5179
+ * A `null` degrades the caller to an empty `FrameResult` — the runner ships
5180
+ * pixels via `frame` as the fallback.
5181
+ */
5182
+ resolveFrameHandle(handle) {
5183
+ if (handle.nodeId !== this.ownNodeId()) return null;
5184
+ if (!this.frameHandleReaders) this.frameHandleReaders = new _camstack_shm_ring.FrameRingReaderCache(this.log);
5185
+ const decoded = this.frameHandleReaders.read(handle);
5186
+ if (!decoded) return null;
5187
+ return {
5188
+ data: decoded.data,
5189
+ width: decoded.width,
5190
+ height: decoded.height,
5191
+ format: decoded.format,
5192
+ timestamp: decoded.timestamp
5193
+ };
5194
+ }
5195
+ /**
5196
+ * Minimal detection-free `FrameResult` returned when a `frameHandle` read
5197
+ * misses (recycled slot / foreign / unmappable). Shape-identical to a
5198
+ * zero-detection runtime result so downstream consumers treat it as a
5199
+ * dropped frame, not an error.
5200
+ */
5201
+ emptyFrameHandleResult(input) {
5202
+ return {
5203
+ kind: "frame",
5204
+ frameId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
5205
+ deviceId: input.deviceId ?? 0,
5206
+ timestamp: Date.now(),
5207
+ width: input.frameHandle?.width ?? 0,
5208
+ height: input.frameHandle?.height ?? 0,
5209
+ detections: [],
5210
+ debug: { totalInferenceMs: 0 }
5211
+ };
5212
+ }
5197
5213
  async runPipeline(input, onProgress) {
5198
5214
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
5199
5215
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
@@ -5229,15 +5245,22 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5229
5245
  let jpegProvider;
5230
5246
  const sources = [
5231
5247
  input.frame ? "frame" : null,
5248
+ input.frameHandle ? "frameHandle" : null,
5232
5249
  input.image ? "image" : null,
5233
5250
  input.imageBase64 ? "imageBase64" : null,
5234
5251
  input.referenceImage ? "referenceImage" : null
5235
5252
  ].filter((s) => s !== null);
5236
- if (sources.length === 0) throw new Error("runPipeline requires exactly one of: frame, image, imageBase64, referenceImage");
5253
+ if (sources.length === 0) throw new Error("runPipeline requires exactly one of: frame, frameHandle, image, imageBase64, referenceImage");
5237
5254
  if (sources.length > 1) throw new Error(`runPipeline received conflicting image sources: ${sources.join(", ")}`);
5255
+ let handleFrame = null;
5256
+ if (input.frameHandle) {
5257
+ handleFrame = this.resolveFrameHandle(input.frameHandle);
5258
+ if (!handleFrame) return this.emptyFrameHandleResult(input);
5259
+ }
5260
+ const runtimeFrame = input.frame ?? handleFrame ?? void 0;
5238
5261
  const decodeT0 = performance.now();
5239
- if (input.frame) {
5240
- const frame = input.frame;
5262
+ if (runtimeFrame) {
5263
+ const frame = runtimeFrame;
5241
5264
  imageWidth = frame.width;
5242
5265
  imageHeight = frame.height;
5243
5266
  const data = Buffer.from(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength);
@@ -5431,7 +5454,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5431
5454
  emit("Running inference...");
5432
5455
  const tree = buildExecutableTree(benchmarkSteps, (stepId) => this.engineFactory.getEngine(stepId));
5433
5456
  setupMs = performance.now() - wallT0 - decodeMs;
5434
- const isRuntime = Boolean(input.frame);
5457
+ const isRuntime = Boolean(runtimeFrame);
5435
5458
  const effectiveDeviceId = input.deviceId ?? 0;
5436
5459
  const deviceOverrides = effectiveDeviceId > 0 ? await this.readDeviceStore(effectiveDeviceId) : {};
5437
5460
  const effectiveTree = Object.keys(deviceOverrides).length > 0 ? applyDeviceOverridesToTree(tree, "object-detection", deviceOverrides) : tree;
@@ -6002,6 +6025,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6002
6025
  this.executor = null;
6003
6026
  }
6004
6027
  for (const id of Array.from(this.deviceProxies.keys())) this.releaseDeviceProxy(id);
6028
+ this.frameHandleReaders?.close();
6029
+ this.frameHandleReaders = null;
6005
6030
  }
6006
6031
  /**
6007
6032
  * Resolve and cache a {@link DeviceProxy} for the given camera. Pins
@@ -6371,37 +6396,12 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6371
6396
  device: best.device ?? null,
6372
6397
  probedBestEngine: probedLabel
6373
6398
  } });
6374
- } else this.log.info("Re-probe: onnx floor pending gpu probe — NOT persisting (re-pick on done)", { meta: {
6399
+ } else this.log.info("Re-probe: onnx floor pending gpu probe — NOT persisting (next boot retries)", { meta: {
6375
6400
  backend: best.backend,
6376
6401
  device: best.device ?? null
6377
6402
  } });
6378
6403
  return best;
6379
6404
  }
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
6405
  async getReferenceAudioFiles() {
6406
6406
  const dir = resolveReferenceAudioDir();
6407
6407
  if (!dir) return [];
@@ -6843,6 +6843,15 @@ function stableEngineKey(engines) {
6843
6843
  return JSON.stringify(stripped);
6844
6844
  }
6845
6845
  /**
6846
+ * Narrow a topology-reported platform string to the runtime-relevant
6847
+ * `NodeJS.Platform` values the engine cascade branches on (darwin/linux/win32).
6848
+ * A type guard, not a cast; any other value → undefined so the caller floors to
6849
+ * `process.platform`. These three match `HardwarePlatformSchema` in the cap.
6850
+ */
6851
+ function toNodePlatform(value) {
6852
+ return value === "darwin" || value === "linux" || value === "win32" ? value : void 0;
6853
+ }
6854
+ /**
6846
6855
  * Tuning fields that bind into the Python pool at construction. Any
6847
6856
  * change requires a pool respawn to take effect (the executor caches
6848
6857
  * `tuning` at engine-factory build time). `onConfigChanged` watches
@@ -6865,13 +6874,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6865
6874
  * Carries the probed node's platform/arch too, so a transient null probe
6866
6875
  * doesn't flip a remote node's env back to the serving hub's platform. */
6867
6876
  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
6877
  engineMetricsTimer = null;
6874
- probePhaseUnsub = null;
6875
6878
  /** Snapshot-equality cache for engine-metrics emit. Most ticks
6876
6879
  * the engine inventory is unchanged (no model load/unload), so
6877
6880
  * we skip the bus emit and let the heartbeat re-emit at
@@ -6919,6 +6922,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6919
6922
  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
6923
  readonlyField: true,
6921
6924
  default: "",
6925
+ perNode: true,
6922
6926
  actions: [{
6923
6927
  action: "reprobe-engine",
6924
6928
  icon: "refresh-cw",
@@ -6939,7 +6943,8 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6939
6943
  label: "Execution provider",
6940
6944
  options: [...STATIC_BACKEND_OPTIONS],
6941
6945
  default: DEFAULT_CONFIG.engineBackend,
6942
- immediate: true
6946
+ immediate: true,
6947
+ perNode: true
6943
6948
  }),
6944
6949
  this.field({
6945
6950
  type: "select",
@@ -6947,7 +6952,8 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6947
6952
  label: "Hardware device",
6948
6953
  options: [...STATIC_DEFAULT_DEVICE_OPTIONS],
6949
6954
  default: DEFAULT_CONFIG.engineDevice,
6950
- immediate: true
6955
+ immediate: true,
6956
+ perNode: true
6951
6957
  })
6952
6958
  ]
6953
6959
  }, {
@@ -7170,14 +7176,37 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7170
7176
  const hardware = await this.resolveProbeHardware(node);
7171
7177
  if (hardware) this.lastGoodHardwareByNode.set(node, hardware);
7172
7178
  const effective = hardware ?? this.lastGoodHardwareByNode.get(node) ?? null;
7173
- const useProbedPlatform = node !== this.localNodeId();
7179
+ const isRemote = node !== this.localNodeId();
7180
+ const topo = isRemote ? await this.resolveTopologyPlatform(node) : null;
7174
7181
  return {
7175
- platform: useProbedPlatform && effective?.platform !== void 0 ? effective.platform : process.platform,
7176
- arch: useProbedPlatform && effective?.arch !== void 0 ? effective.arch : process.arch,
7182
+ platform: (isRemote ? topo?.platform ?? effective?.platform : void 0) ?? process.platform,
7183
+ arch: (isRemote ? topo?.arch ?? effective?.arch : void 0) ?? process.arch,
7177
7184
  hardware: effective
7178
7185
  };
7179
7186
  }
7180
7187
  /**
7188
+ * Read the TARGET node's platform/arch from cluster topology — the
7189
+ * registration self-report (`nodes.topology`), which is hub-local and
7190
+ * available immediately, independent of the node's async accelerator probe.
7191
+ * Mirrors the audio analyzer's `resolveNodePlatform` (the D6 reference
7192
+ * pattern) so detection + audio share ONE topology-read path. Returns null
7193
+ * when topology is unreachable (caller falls back to the probe report, then
7194
+ * `process.platform`). Narrows the reported platform to the runtime-relevant
7195
+ * `NodeJS.Platform` values via a type guard (no cast); any other value is
7196
+ * dropped so the caller floors safely.
7197
+ */
7198
+ async resolveTopologyPlatform(nodeId) {
7199
+ try {
7200
+ const picked = require_node_topology_platform.pickNodePlatformArch(await this.ctxIfReady?.api?.nodes?.topology.query(), nodeId);
7201
+ return {
7202
+ platform: toNodePlatform(picked.platform),
7203
+ arch: picked.arch
7204
+ };
7205
+ } catch {
7206
+ return null;
7207
+ }
7208
+ }
7209
+ /**
7181
7210
  * Fetch the probed hardware from the platform-probe cap for `nodeId` (default
7182
7211
  * = self). Returns null when the cap is not reachable (caller falls back to
7183
7212
  * the registry's safe minimum). Carries the probed node's `platform`/`arch`
@@ -7214,81 +7243,6 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7214
7243
  return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
7215
7244
  }
7216
7245
  /**
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
7246
  * Resolve the effective pool tuning for the configured backend.
7293
7247
  *
7294
7248
  * Reads the registry's `tuningFor(backend)` and ignores any persisted
@@ -7302,7 +7256,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7302
7256
  * a reason to disagree.
7303
7257
  */
7304
7258
  resolveBackendTuning() {
7305
- const t = tuningFor(toRuntimeId(this.nodeEngineBackend ?? DEFAULT_CONFIG.engineBackend));
7259
+ const t = tuningFor(toRuntimeId(this.config.engineBackend));
7306
7260
  const num = (v, dflt) => typeof v === "number" && v > 0 ? v : dflt;
7307
7261
  const batch = (v, dflt) => v === "none" || v === "list" || v === "window" ? v : dflt;
7308
7262
  return {
@@ -7318,8 +7272,6 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7318
7272
  async onInitialize() {
7319
7273
  const modelsDir = await this.resolveModelsDir();
7320
7274
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7321
- await this.migrateLegacyBareEngineKeys();
7322
- await this.refreshNodeEngineFromStore();
7323
7275
  this.pythonAddonDir = resolveAddonPythonDir();
7324
7276
  const py = await ensurePythonReady(this.ctx.deps, this.ctx.logger);
7325
7277
  if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
@@ -7341,18 +7293,9 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7341
7293
  });
7342
7294
  await this.provider.init();
7343
7295
  await this.provider.setApi(this.ctx);
7344
- if (!this.nodeProbedBestEngine) await this.provider.reprobeEngine().catch((err) => {
7296
+ if (!this.config.probedBestEngine) await this.provider.reprobeEngine().catch((err) => {
7345
7297
  this.ctx.logger.warn("auto-reprobe engine failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7346
7298
  });
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
7299
  await this.provider.warmPool();
7357
7300
  this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
7358
7301
  this.lastAppliedPoolConfig = this.snapshotPoolConfig();
@@ -7426,10 +7369,6 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7426
7369
  }
7427
7370
  }
7428
7371
  async onShutdown() {
7429
- if (this.probePhaseUnsub) {
7430
- this.probePhaseUnsub();
7431
- this.probePhaseUnsub = null;
7432
- }
7433
7372
  if (this.engineMetricsTimer) {
7434
7373
  clearInterval(this.engineMetricsTimer);
7435
7374
  this.engineMetricsTimer = null;
@@ -7477,7 +7416,6 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7477
7416
  * re-spin, frames are dropped (never crashed), no addon bounce.
7478
7417
  */
7479
7418
  async onConfigChanged() {
7480
- await this.refreshNodeEngineFromStore();
7481
7419
  if (this.provider) await this.provider.onEngineSelectionChanged().catch((err) => {
7482
7420
  this.ctx.logger.warn("engine provisioning re-select failed on config change", { meta: { error: err instanceof Error ? err.message : String(err) } });
7483
7421
  });