@camstack/addon-pipeline 1.1.26 → 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 (48) 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/constants-CAqYVigN.js +6615 -0
  6. package/dist/constants-oYgW36Ty.mjs +3587 -0
  7. package/dist/decoder-ffmpeg/index.js +74 -281
  8. package/dist/decoder-ffmpeg/index.mjs +73 -280
  9. package/dist/decoder-nodeav/index.js +1218 -0
  10. package/dist/decoder-nodeav/index.mjs +1210 -0
  11. package/dist/detection-pipeline/index.js +157 -219
  12. package/dist/detection-pipeline/index.mjs +157 -219
  13. package/dist/{dist-DAIlCdAx.js → dist-Biq62zt4.js} +372 -35
  14. package/dist/{dist-CgEP_0OL.mjs → dist-C6_wgXqF.mjs} +373 -24
  15. package/dist/dist-D3Ytt-_Y.js +41111 -0
  16. package/dist/dist-DluJuxV5.mjs +37976 -0
  17. package/dist/{frame-handle-plane-Dq20KtKL.mjs → frame-handle-plane-BIoY6nRV.mjs} +13 -5
  18. package/dist/{frame-handle-plane-DtTRX_0n.js → frame-handle-plane-D6BzyEgy.js} +13 -5
  19. package/dist/frame-ring-sink-9J0wCdLF.js +444 -0
  20. package/dist/frame-ring-sink-Cs9vby6v.mjs +409 -0
  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-DoReAb4y.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-C0SuwQhL.mjs} +3 -3
  34. package/dist/stream-broker/index.js +81 -40
  35. package/dist/stream-broker/index.mjs +81 -40
  36. package/dist/stream-broker/remoteEntry.js +1 -1
  37. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-BDLNwJ_F.js → MaskShapeCanvas-DI4BY7W2-Br2yi8ah.js} +1 -1
  38. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CoLjNiUN.js → MotionZonesSettings-NcxxQN8r-BSCcPdPf.js} +1 -1
  39. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-DJE3OU-q.js → PrivacyMaskSettings-APgPLF7p-ztxvxSjS.js} +1 -1
  40. package/embed-dist/assets/index-C5UpuPr8.css +2 -0
  41. package/embed-dist/assets/{index-C-pL8ETk.js → index-CeZV1B-2.js} +10 -10
  42. package/embed-dist/index.html +2 -2
  43. package/package.json +31 -3
  44. package/python/inference_pool.py +108 -121
  45. package/python/test_inference_pool_device_selection.py +47 -43
  46. package/python/yamnet_audio.py +1 -1
  47. 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
  48. package/embed-dist/assets/index-DrJ0ee3f.css +0 -2
@@ -1,6 +1,8 @@
1
1
  import { t as __require } from "../chunk-BdkLduGY.mjs";
2
- import { B as errMsg, E as hfModelUrl, F as runtimeDevices$1, G as EventCategory, J as createEvent, M as pipelineExecutorCapability, Q as parseJsonUnknown, R as supportedRuntimes$1, S as defaultDeviceFor$1, T as evaluateZoneRules, V as BaseAddon, Y as hydrateSchema, a as COCO_TO_MACRO, i as COCO_80_LABELS, j as nodePin, m as YAMNET_TO_MACRO, r as AUDIO_MACRO_LABELS, t as APPLE_SA_TO_MACRO, tt as sleep, w as detectionPipelineCapability } from "../dist-CgEP_0OL.mjs";
2
+ import { $ as sleep, B as errMsg, E as hfModelUrl, F as runtimeDevices$1, G as EventCategory, K as createEvent, M as pipelineExecutorCapability, R as supportedRuntimes$1, S as defaultDeviceFor$1, T as evaluateZoneRules, V as BaseAddon, X as parseJsonUnknown, a as COCO_TO_MACRO, i as COCO_80_LABELS, j as nodePin, m as YAMNET_TO_MACRO, q as hydrateSchema, r as AUDIO_MACRO_LABELS, t as APPLE_SA_TO_MACRO, w as detectionPipelineCapability } from "../dist-C6_wgXqF.mjs";
3
+ import { t as pickNodePlatformArch } from "../node-topology-platform-BkR_k6WT.mjs";
3
4
  import { a as isModelDownloaded, i as ensureModel, n as deleteModelFromDisk } from "../model-download-service-C-IHWnXx-BPy6aoAx.mjs";
5
+ import { FrameRingReaderCache } from "@camstack/shm-ring";
4
6
  import * as fs from "node:fs";
5
7
  import * as path$1 from "node:path";
6
8
  import * as os from "node:os";
@@ -20,13 +22,15 @@ import sharp from "sharp";
20
22
  * another node that has no NPU.
21
23
  *
22
24
  * These three keys are therefore persisted node-scoped as `<key>@<nodeId>`.
23
- * Everything else in the store stays shared. A legacy un-scoped value (written
24
- * before this change, or by an older build) is read as a migration fallback
25
- * for the HUB ONLY the pre-scoping store was only ever written by a
26
- * hub-only deployment, so serving the bare value to any other node leaks the
27
- * hub's hardware pick (e.g. `openvino/auto`) onto a node that cannot run it
28
- * (the CB3 "Mac runs OpenVINO" / N100 AUTO-device bug). A non-hub node with
29
- * no scoped key reads `undefined`, which drives its own auto-pick.
25
+ * Everything else in the store stays shared. There is NO fallback to a legacy
26
+ * un-scoped value for ANY node (the hub included) and no in-code migration: a
27
+ * bare key (written before scoping, or by an older build) is inert invisible
28
+ * to every node. A node with no scoped key reads `undefined`, which drives its
29
+ * OWN platform auto-pick (`autoPickAndPersist` / `reprobeEngine` persist the
30
+ * node's own probe under `<key>@<node>`). Serving a bare value to any node
31
+ * would leak one node's hardware pick (e.g. `openvino/auto`) onto a node that
32
+ * cannot run it (the CB3 "Mac runs OpenVINO" / N100 AUTO-device bug). This
33
+ * mirrors the decoder's `readNodeDecoderBackend` no-fallback rule.
30
34
  */
31
35
  var ENGINE_CASCADE_KEYS = [
32
36
  "engineBackend",
@@ -51,27 +55,14 @@ function nodeEngineKey(base, nodeId) {
51
55
  }
52
56
  /**
53
57
  * Read an engine cascade value for a node: the node-scoped key if present,
54
- * otherwise — for the HUB only the legacy un-scoped value (migration),
55
- * otherwise undefined. The bare fallback is hub-restricted because only the
56
- * hub ever wrote the pre-scoping store; serving it to other nodes leaked the
57
- * hub's engine selection cluster-wide (CB3). A non-hub node with no scoped
58
- * key gets `undefined` so its own platform-driven auto-pick runs and persists
59
- * a real per-node selection.
58
+ * otherwise `undefined` — for EVERY node, the hub included. There is no
59
+ * fallback to the legacy un-scoped key: a bare value in the store is inert.
60
+ * A node with no scoped key gets `undefined` so its own platform-driven
61
+ * auto-pick runs and persists a real per-node selection under `<key>@<node>`.
62
+ * (A bare fallback would leak one node's engine selection cluster-wide CB3.)
60
63
  */
61
64
  function readNodeEngineValue(store, base, nodeId) {
62
- const scoped = store[nodeEngineKey(base, nodeId)];
63
- if (scoped !== void 0) return scoped;
64
- return normalizeEngineNodeId(nodeId) === "hub" ? store[base] : void 0;
65
- }
66
- /**
67
- * Re-map a UI/settings patch so engine cascade fields persist under the
68
- * node-scoped key; all other keys pass through unchanged. Used on the write
69
- * path so a UI save on one node never clobbers another node's engine.
70
- */
71
- function scopeEnginePatch(patch, nodeId) {
72
- const out = {};
73
- for (const [key, value] of Object.entries(patch)) out[isEngineCascadeKey(key) ? nodeEngineKey(key, nodeId) : key] = value;
74
- return out;
65
+ return store[nodeEngineKey(base, nodeId)];
75
66
  }
76
67
  /**
77
68
  * Project a raw store onto the plain engine cascade keys for THIS node, so the
@@ -4341,6 +4332,15 @@ var ONNX_FLOOR = {
4341
4332
  device: "cpu"
4342
4333
  };
4343
4334
  /**
4335
+ * How long the first-boot pick waits for the node's `platform-probe` to flip
4336
+ * `ready` (= its FULL async probe done, embedded-Python install included).
4337
+ * MUST stay finite — `readinessRegistry.awaitReady` defaults to Infinity,
4338
+ * which would wedge `setApi` (and the addon's onInitialize) forever on a
4339
+ * node whose probe never completes. On timeout the pick proceeds
4340
+ * conservatively (platform floor, never persisted when non-authoritative).
4341
+ */
4342
+ var PROBE_READY_TIMEOUT_MS = 12e4;
4343
+ /**
4344
4344
  * Build the onnx-cpu floor pick using `pickBestRuntime` with a null hardware
4345
4345
  * env. Used wherever the old `detectBestEngine()` sync probe fell back — the
4346
4346
  * result is identical (onnx / cpu) but is now derived through the shared rules
@@ -4419,6 +4419,16 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4419
4419
  */
4420
4420
  lastStepsSignature = null;
4421
4421
  /**
4422
+ * CB5 shm passthrough — the process-local, LONG-LIVED reader cache for
4423
+ * `runPipeline({ frameHandle })`. Opens each named shm segment ONCE and
4424
+ * reuses the mapping for every later handle on the same ring (never
4425
+ * open/close per-call). Lazily created on first handle, unmapped in
4426
+ * {@link shutdown}. The executor and the runner are both hub-local
4427
+ * processes sharing `/dev/shm`, so a LOCAL handle resolves to a zero-copy
4428
+ * read here.
4429
+ */
4430
+ frameHandleReaders = null;
4431
+ /**
4422
4432
  * True once the engine + models are fully ready for inference. No
4423
4433
  * longer gates runtime dispatch (Phase 4 removed the legacy `runFrame`
4424
4434
  * which read this flag); kept as a diagnostic the admin UI / tests
@@ -4444,13 +4454,6 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4444
4454
  */
4445
4455
  needsAutoPick = false;
4446
4456
  /**
4447
- * Unsubscribe handle for the deferred-auto-pick `platform-probe` ready
4448
- * listener (armed in `setApi` when the probe isn't ready yet). Cleared once
4449
- * the engine is resolved — either by the listener firing or by the boot
4450
- * safety-net `ensureBootEngineProvisioned`.
4451
- */
4452
- deferredAutoPickUnsub = null;
4453
- /**
4454
4457
  * Warm cache for benchmark engine-override runs.
4455
4458
  *
4456
4459
  * Each override rebuild costs a full Python pool spin-up (~300-500ms)
@@ -4551,60 +4554,26 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4551
4554
  /** Store the addon context. ctx.api is a lazy getter resolved at call time. */
4552
4555
  async setApi(addonCtx) {
4553
4556
  this.addonCtx = addonCtx;
4554
- if (this.needsAutoPick) if (this.addonCtx.useCapability("platform-probe").isReady) {
4555
- await this.autoPickAndPersist();
4556
- this.needsAutoPick = false;
4557
+ if (!this.needsAutoPick) {
4557
4558
  this.startProvisioningForCurrentEngine();
4558
- } else {
4559
- const unsubscribe = this.addonCtx.onCapabilityStateChange("platform-probe", { type: "global" }, (state) => {
4560
- if (state !== "ready") return;
4561
- this.cancelDeferredAutoPick();
4562
- if (!this.needsAutoPick) return;
4563
- this.autoPickAndPersist().then(() => {
4564
- this.needsAutoPick = false;
4565
- this.startProvisioningForCurrentEngine();
4566
- });
4567
- });
4568
- this.deferredAutoPickUnsub = unsubscribe;
4569
- this.addonCtx.addDisposer(() => this.cancelDeferredAutoPick());
4559
+ return;
4570
4560
  }
4571
- else this.startProvisioningForCurrentEngine();
4572
- }
4573
- /** Tear down the deferred-auto-pick probe listener, if still armed. */
4574
- cancelDeferredAutoPick() {
4575
- if (this.deferredAutoPickUnsub) {
4576
- this.deferredAutoPickUnsub();
4577
- this.deferredAutoPickUnsub = null;
4561
+ const nodeId = this.localProbeNodeId();
4562
+ const registry = this.addonCtx.kernel?.readinessRegistry;
4563
+ if (registry) try {
4564
+ await registry.awaitReady("platform-probe", {
4565
+ type: "node",
4566
+ nodeId
4567
+ }, { timeoutMs: PROBE_READY_TIMEOUT_MS });
4568
+ } catch (err) {
4569
+ this.log.warn("platform-probe readiness gate timed out — conservative engine pick (floor is not persisted)", { meta: {
4570
+ nodeId,
4571
+ timeoutMs: PROBE_READY_TIMEOUT_MS,
4572
+ error: errMsg(err)
4573
+ } });
4578
4574
  }
4579
- }
4580
- /**
4581
- * Boot safety-net: deterministically provision the hardware-probed engine.
4582
- *
4583
- * Called from the addon's `onInitialize` right after `reprobeEngine` has
4584
- * written this node's probe-driven selection (e.g. `engineBackend=openvino`)
4585
- * to the store. When first-boot auto-pick was DEFERRED (probe not ready at
4586
- * `setApi`), the engine would otherwise stay `idle` — selected but never
4587
- * provisioned — because the `onCapabilityStateChange('platform-probe')` ready
4588
- * edge can be missed when the cap is already 'ready' by the time we subscribe
4589
- * (the old eager-onnx-floor masked this; removing it surfaced a node that
4590
- * boots with no engine at all). This loads the now-persisted selection and
4591
- * starts provisioning it, so the node reliably comes up on its real best
4592
- * engine (openvino on Intel) with no onnx floor and no missed boot. No-op
4593
- * when provisioning already started (persisted-engine path / listener fired)
4594
- * or when nothing has been selected yet.
4595
- */
4596
- async ensureBootEngineProvisioned() {
4597
- if (this.getEngineProvisioning().state !== "idle") return;
4598
- const stored = await this.loadEngine();
4599
- if (!stored) return;
4600
- this.currentEngine = stored;
4575
+ await this.autoPickAndPersist();
4601
4576
  this.needsAutoPick = false;
4602
- this.cancelDeferredAutoPick();
4603
- this.log.info("Boot engine provisioning from probed selection", { meta: {
4604
- runtime: stored.runtime,
4605
- backend: stored.backend,
4606
- device: stored.device ?? null
4607
- } });
4608
4577
  this.startProvisioningForCurrentEngine();
4609
4578
  }
4610
4579
  /**
@@ -4636,7 +4605,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4636
4605
  };
4637
4606
  this.currentEngine = engine;
4638
4607
  if (!(pick.runtimeId !== "onnx" || hardware !== null)) {
4639
- this.log.info("Auto-pick: onnx floor pending gpu probe — NOT persisting (re-pick on done)", { meta: {
4608
+ this.log.info("Auto-pick: onnx floor pending gpu probe — NOT persisting (next boot retries)", { meta: {
4640
4609
  backend: pick.runtimeId,
4641
4610
  device: pick.device
4642
4611
  } });
@@ -5187,6 +5156,53 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5187
5156
  } });
5188
5157
  return { success: true };
5189
5158
  }
5159
+ /** This executor's cluster node id (suffix-stripped, matching the runner). */
5160
+ ownNodeId() {
5161
+ const raw = this.addonCtx?.kernel?.localNodeId ?? "hub";
5162
+ return raw.includes("/") ? raw.split("/")[0] : raw;
5163
+ }
5164
+ /**
5165
+ * CB5 — resolve a shm `FrameHandle` to pixels zero-copy. Returns `null`
5166
+ * when:
5167
+ * - the handle is FOREIGN (its segment lives on another node — a local
5168
+ * `shm_open` would flood ENOENT; the runner only sends LOCAL handles, so
5169
+ * this is a defensive guard against an unexpected topology);
5170
+ * - the ring slot was recycled before the read (latest-wins drop) or the
5171
+ * segment could not be mapped.
5172
+ * A `null` degrades the caller to an empty `FrameResult` — the runner ships
5173
+ * pixels via `frame` as the fallback.
5174
+ */
5175
+ resolveFrameHandle(handle) {
5176
+ if (handle.nodeId !== this.ownNodeId()) return null;
5177
+ if (!this.frameHandleReaders) this.frameHandleReaders = new FrameRingReaderCache(this.log);
5178
+ const decoded = this.frameHandleReaders.read(handle);
5179
+ if (!decoded) return null;
5180
+ return {
5181
+ data: decoded.data,
5182
+ width: decoded.width,
5183
+ height: decoded.height,
5184
+ format: decoded.format,
5185
+ timestamp: decoded.timestamp
5186
+ };
5187
+ }
5188
+ /**
5189
+ * Minimal detection-free `FrameResult` returned when a `frameHandle` read
5190
+ * misses (recycled slot / foreign / unmappable). Shape-identical to a
5191
+ * zero-detection runtime result so downstream consumers treat it as a
5192
+ * dropped frame, not an error.
5193
+ */
5194
+ emptyFrameHandleResult(input) {
5195
+ return {
5196
+ kind: "frame",
5197
+ frameId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
5198
+ deviceId: input.deviceId ?? 0,
5199
+ timestamp: Date.now(),
5200
+ width: input.frameHandle?.width ?? 0,
5201
+ height: input.frameHandle?.height ?? 0,
5202
+ detections: [],
5203
+ debug: { totalInferenceMs: 0 }
5204
+ };
5205
+ }
5190
5206
  async runPipeline(input, onProgress) {
5191
5207
  const nodeId = this.addonCtx?.kernel?.localNodeId ?? "hub";
5192
5208
  const sessionId = input.sessionId ?? `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
@@ -5222,15 +5238,22 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5222
5238
  let jpegProvider;
5223
5239
  const sources = [
5224
5240
  input.frame ? "frame" : null,
5241
+ input.frameHandle ? "frameHandle" : null,
5225
5242
  input.image ? "image" : null,
5226
5243
  input.imageBase64 ? "imageBase64" : null,
5227
5244
  input.referenceImage ? "referenceImage" : null
5228
5245
  ].filter((s) => s !== null);
5229
- if (sources.length === 0) throw new Error("runPipeline requires exactly one of: frame, image, imageBase64, referenceImage");
5246
+ if (sources.length === 0) throw new Error("runPipeline requires exactly one of: frame, frameHandle, image, imageBase64, referenceImage");
5230
5247
  if (sources.length > 1) throw new Error(`runPipeline received conflicting image sources: ${sources.join(", ")}`);
5248
+ let handleFrame = null;
5249
+ if (input.frameHandle) {
5250
+ handleFrame = this.resolveFrameHandle(input.frameHandle);
5251
+ if (!handleFrame) return this.emptyFrameHandleResult(input);
5252
+ }
5253
+ const runtimeFrame = input.frame ?? handleFrame ?? void 0;
5231
5254
  const decodeT0 = performance.now();
5232
- if (input.frame) {
5233
- const frame = input.frame;
5255
+ if (runtimeFrame) {
5256
+ const frame = runtimeFrame;
5234
5257
  imageWidth = frame.width;
5235
5258
  imageHeight = frame.height;
5236
5259
  const data = Buffer.from(frame.data.buffer, frame.data.byteOffset, frame.data.byteLength);
@@ -5424,7 +5447,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5424
5447
  emit("Running inference...");
5425
5448
  const tree = buildExecutableTree(benchmarkSteps, (stepId) => this.engineFactory.getEngine(stepId));
5426
5449
  setupMs = performance.now() - wallT0 - decodeMs;
5427
- const isRuntime = Boolean(input.frame);
5450
+ const isRuntime = Boolean(runtimeFrame);
5428
5451
  const effectiveDeviceId = input.deviceId ?? 0;
5429
5452
  const deviceOverrides = effectiveDeviceId > 0 ? await this.readDeviceStore(effectiveDeviceId) : {};
5430
5453
  const effectiveTree = Object.keys(deviceOverrides).length > 0 ? applyDeviceOverridesToTree(tree, "object-detection", deviceOverrides) : tree;
@@ -5995,6 +6018,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5995
6018
  this.executor = null;
5996
6019
  }
5997
6020
  for (const id of Array.from(this.deviceProxies.keys())) this.releaseDeviceProxy(id);
6021
+ this.frameHandleReaders?.close();
6022
+ this.frameHandleReaders = null;
5998
6023
  }
5999
6024
  /**
6000
6025
  * Resolve and cache a {@link DeviceProxy} for the given camera. Pins
@@ -6364,37 +6389,12 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6364
6389
  device: best.device ?? null,
6365
6390
  probedBestEngine: probedLabel
6366
6391
  } });
6367
- } else this.log.info("Re-probe: onnx floor pending gpu probe — NOT persisting (re-pick on done)", { meta: {
6392
+ } else this.log.info("Re-probe: onnx floor pending gpu probe — NOT persisting (next boot retries)", { meta: {
6368
6393
  backend: best.backend,
6369
6394
  device: best.device ?? null
6370
6395
  } });
6371
6396
  return best;
6372
6397
  }
6373
- /**
6374
- * Re-pick the engine when the platform-probe finishes its async hardware +
6375
- * Python detection (the `platform-probe.phase` `done` event). At boot the
6376
- * probe's accelerator result may not be ready yet, so the engine floored to
6377
- * onnx; once the probe answers (e.g. a Mac's CoreML/ANE surfaces after the
6378
- * embedded Python is installed) this re-runs the probe-driven pick and
6379
- * re-provisions. Idempotent: `startProvisioningForCurrentEngine` skips a
6380
- * no-op when the selection is unchanged.
6381
- */
6382
- async repickEngineOnProbeReady() {
6383
- const before = `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`;
6384
- await this.reprobeEngine();
6385
- const stored = await this.loadEngine();
6386
- if (stored) {
6387
- this.currentEngine = stored;
6388
- this.needsAutoPick = false;
6389
- this.cancelDeferredAutoPick();
6390
- }
6391
- const after = `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`;
6392
- if (before !== after) this.log.info("Engine re-picked after platform-probe completed", { meta: {
6393
- before,
6394
- after
6395
- } });
6396
- this.startProvisioningForCurrentEngine();
6397
- }
6398
6398
  async getReferenceAudioFiles() {
6399
6399
  const dir = resolveReferenceAudioDir();
6400
6400
  if (!dir) return [];
@@ -6836,6 +6836,15 @@ function stableEngineKey(engines) {
6836
6836
  return JSON.stringify(stripped);
6837
6837
  }
6838
6838
  /**
6839
+ * Narrow a topology-reported platform string to the runtime-relevant
6840
+ * `NodeJS.Platform` values the engine cascade branches on (darwin/linux/win32).
6841
+ * A type guard, not a cast; any other value → undefined so the caller floors to
6842
+ * `process.platform`. These three match `HardwarePlatformSchema` in the cap.
6843
+ */
6844
+ function toNodePlatform(value) {
6845
+ return value === "darwin" || value === "linux" || value === "win32" ? value : void 0;
6846
+ }
6847
+ /**
6839
6848
  * Tuning fields that bind into the Python pool at construction. Any
6840
6849
  * change requires a pool respawn to take effect (the executor caches
6841
6850
  * `tuning` at engine-factory build time). `onConfigChanged` watches
@@ -6858,13 +6867,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6858
6867
  * Carries the probed node's platform/arch too, so a transient null probe
6859
6868
  * doesn't flip a remote node's env back to the serving hub's platform. */
6860
6869
  lastGoodHardwareByNode = /* @__PURE__ */ new Map();
6861
- /** This node's effective engine selection, cached from the node-scoped store
6862
- * so the synchronous `resolveBackendTuning` and the reprobe gate don't read
6863
- * the cluster-shared bare keys. Refreshed at init + on every config change. */
6864
- nodeEngineBackend = DEFAULT_CONFIG.engineBackend;
6865
- nodeProbedBestEngine = "";
6866
6870
  engineMetricsTimer = null;
6867
- probePhaseUnsub = null;
6868
6871
  /** Snapshot-equality cache for engine-metrics emit. Most ticks
6869
6872
  * the engine inventory is unchanged (no model load/unload), so
6870
6873
  * we skip the bus emit and let the heartbeat re-emit at
@@ -6912,6 +6915,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6912
6915
  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.",
6913
6916
  readonlyField: true,
6914
6917
  default: "",
6918
+ perNode: true,
6915
6919
  actions: [{
6916
6920
  action: "reprobe-engine",
6917
6921
  icon: "refresh-cw",
@@ -6932,7 +6936,8 @@ var DetectionPipelineAddon = class extends BaseAddon {
6932
6936
  label: "Execution provider",
6933
6937
  options: [...STATIC_BACKEND_OPTIONS],
6934
6938
  default: DEFAULT_CONFIG.engineBackend,
6935
- immediate: true
6939
+ immediate: true,
6940
+ perNode: true
6936
6941
  }),
6937
6942
  this.field({
6938
6943
  type: "select",
@@ -6940,7 +6945,8 @@ var DetectionPipelineAddon = class extends BaseAddon {
6940
6945
  label: "Hardware device",
6941
6946
  options: [...STATIC_DEFAULT_DEVICE_OPTIONS],
6942
6947
  default: DEFAULT_CONFIG.engineDevice,
6943
- immediate: true
6948
+ immediate: true,
6949
+ perNode: true
6944
6950
  })
6945
6951
  ]
6946
6952
  }, {
@@ -7163,14 +7169,37 @@ var DetectionPipelineAddon = class extends BaseAddon {
7163
7169
  const hardware = await this.resolveProbeHardware(node);
7164
7170
  if (hardware) this.lastGoodHardwareByNode.set(node, hardware);
7165
7171
  const effective = hardware ?? this.lastGoodHardwareByNode.get(node) ?? null;
7166
- const useProbedPlatform = node !== this.localNodeId();
7172
+ const isRemote = node !== this.localNodeId();
7173
+ const topo = isRemote ? await this.resolveTopologyPlatform(node) : null;
7167
7174
  return {
7168
- platform: useProbedPlatform && effective?.platform !== void 0 ? effective.platform : process.platform,
7169
- arch: useProbedPlatform && effective?.arch !== void 0 ? effective.arch : process.arch,
7175
+ platform: (isRemote ? topo?.platform ?? effective?.platform : void 0) ?? process.platform,
7176
+ arch: (isRemote ? topo?.arch ?? effective?.arch : void 0) ?? process.arch,
7170
7177
  hardware: effective
7171
7178
  };
7172
7179
  }
7173
7180
  /**
7181
+ * Read the TARGET node's platform/arch from cluster topology — the
7182
+ * registration self-report (`nodes.topology`), which is hub-local and
7183
+ * available immediately, independent of the node's async accelerator probe.
7184
+ * Mirrors the audio analyzer's `resolveNodePlatform` (the D6 reference
7185
+ * pattern) so detection + audio share ONE topology-read path. Returns null
7186
+ * when topology is unreachable (caller falls back to the probe report, then
7187
+ * `process.platform`). Narrows the reported platform to the runtime-relevant
7188
+ * `NodeJS.Platform` values via a type guard (no cast); any other value is
7189
+ * dropped so the caller floors safely.
7190
+ */
7191
+ async resolveTopologyPlatform(nodeId) {
7192
+ try {
7193
+ const picked = pickNodePlatformArch(await this.ctxIfReady?.api?.nodes?.topology.query(), nodeId);
7194
+ return {
7195
+ platform: toNodePlatform(picked.platform),
7196
+ arch: picked.arch
7197
+ };
7198
+ } catch {
7199
+ return null;
7200
+ }
7201
+ }
7202
+ /**
7174
7203
  * Fetch the probed hardware from the platform-probe cap for `nodeId` (default
7175
7204
  * = self). Returns null when the cap is not reachable (caller falls back to
7176
7205
  * the registry's safe minimum). Carries the probed node's `platform`/`arch`
@@ -7207,81 +7236,6 @@ var DetectionPipelineAddon = class extends BaseAddon {
7207
7236
  return normalizeEngineNodeId(this.ctxIfReady?.kernel?.localNodeId ?? "hub");
7208
7237
  }
7209
7238
  /**
7210
- * One-time store migration (hub only, F1/CB3): the pre-scoping store
7211
- * persisted the engine cascade under BARE keys (`engineBackend`, …) that
7212
- * only the hub ever wrote. `readNodeEngineValue` used to serve those bare
7213
- * values as a fallback to EVERY node, leaking the hub's engine (e.g.
7214
- * `openvino/auto`) onto agents with no scoped key. The fallback is now
7215
- * hub-only (engine-store-keys.ts) and this migration re-homes the bare
7216
- * values to their `<key>@hub` scoped form and deletes the bare keys so the
7217
- * legacy values cannot resurface. Guarded: runs only while a bare key is
7218
- * still present, so post-migration boots are a no-op. Best-effort — an
7219
- * unreadable store skips the migration (retried on the next boot).
7220
- */
7221
- async migrateLegacyBareEngineKeys() {
7222
- if (this.localNodeId() !== "hub") return;
7223
- const store = await this.resolveUiSettingsStore();
7224
- if (store === null) return;
7225
- const patch = {};
7226
- for (const base of ENGINE_CASCADE_KEYS) {
7227
- const bare = store[base];
7228
- if (bare === void 0 || bare === null) continue;
7229
- const scopedKey = nodeEngineKey(base, "hub");
7230
- if (store[scopedKey] === void 0) patch[scopedKey] = bare;
7231
- patch[base] = void 0;
7232
- }
7233
- if (Object.keys(patch).length === 0) return;
7234
- await this.ctxIfReady?.settings?.writeAddonStore(patch);
7235
- this.ctx.logger.info("Migrated legacy bare engine keys to their @hub scoped form", { meta: { keys: Object.keys(patch) } });
7236
- }
7237
- /**
7238
- * Refresh this node's cached engine selection from the node-scoped store.
7239
- * `resolveBackendTuning` is synchronous and the reprobe gate runs before the
7240
- * provider exists, so both read these cached fields instead of the
7241
- * cluster-shared bare keys (which belong to no single node). Best-effort: a
7242
- * transiently-unreadable store leaves the last cached values in place.
7243
- */
7244
- async refreshNodeEngineFromStore() {
7245
- const store = await this.resolveUiSettingsStore();
7246
- if (store === null) return;
7247
- const node = this.localNodeId();
7248
- const backend = readNodeEngineValue(store, "engineBackend", node);
7249
- if (typeof backend === "string" && backend !== "") this.nodeEngineBackend = backend;
7250
- const probed = readNodeEngineValue(store, "probedBestEngine", node);
7251
- this.nodeProbedBestEngine = typeof probed === "string" ? probed : "";
7252
- }
7253
- /**
7254
- * Persist a settings patch, mirroring the engine cascade fields to the TARGET
7255
- * node's scoped keys so each node keeps an INDEPENDENT engine selection in the
7256
- * cluster-central store. The hub addon serves writes for every node, so it
7257
- * scopes by the requested `nodeId` (default self).
7258
- *
7259
- * The engine cascade keys are NEVER written bare (F1, CB3): the bare keys are
7260
- * the legacy pre-scoping store whose values used to leak to every node with
7261
- * no scoped key. When the target IS this node, `super.updateGlobalSettings`
7262
- * drives the normal apply path (`resolveConfig` / `onConfigChanged` /
7263
- * `requiresRestart` restart) with the engine keys STRIPPED from its patch —
7264
- * they were already persisted node-scoped, and `onConfigChanged` re-reads
7265
- * them from the scoped keys. When the target is a SIBLING node, one scoped
7266
- * write persists the engine keys under `<key>@<target>` + the non-engine
7267
- * keys bare, and this node's restart/reprovision is NOT run — the owning
7268
- * node applies its own engine selection on its next (re)start.
7269
- */
7270
- async updateGlobalSettings(patch, nodeId) {
7271
- const targetNode = nodeId ? normalizeEngineNodeId(nodeId) : this.localNodeId();
7272
- const patchRecord = patch;
7273
- if (targetNode !== this.localNodeId()) {
7274
- const scoped = scopeEnginePatch(patchRecord, targetNode);
7275
- if (Object.keys(scoped).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(scoped);
7276
- return;
7277
- }
7278
- const scopedEngine = {};
7279
- for (const key of ENGINE_CASCADE_KEYS) if (key in patchRecord) scopedEngine[nodeEngineKey(key, targetNode)] = patchRecord[key];
7280
- if (Object.keys(scopedEngine).length > 0) await this.ctxIfReady?.settings?.writeAddonStore(scopedEngine);
7281
- const { engineBackend: _engineBackend, engineDevice: _engineDevice, probedBestEngine: _probedBestEngine, ...sharedPatch } = patch;
7282
- await super.updateGlobalSettings(sharedPatch, nodeId);
7283
- }
7284
- /**
7285
7239
  * Resolve the effective pool tuning for the configured backend.
7286
7240
  *
7287
7241
  * Reads the registry's `tuningFor(backend)` and ignores any persisted
@@ -7295,7 +7249,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
7295
7249
  * a reason to disagree.
7296
7250
  */
7297
7251
  resolveBackendTuning() {
7298
- const t = tuningFor(toRuntimeId(this.nodeEngineBackend ?? DEFAULT_CONFIG.engineBackend));
7252
+ const t = tuningFor(toRuntimeId(this.config.engineBackend));
7299
7253
  const num = (v, dflt) => typeof v === "number" && v > 0 ? v : dflt;
7300
7254
  const batch = (v, dflt) => v === "none" || v === "list" || v === "window" ? v : dflt;
7301
7255
  return {
@@ -7311,8 +7265,6 @@ var DetectionPipelineAddon = class extends BaseAddon {
7311
7265
  async onInitialize() {
7312
7266
  const modelsDir = await this.resolveModelsDir();
7313
7267
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
7314
- await this.migrateLegacyBareEngineKeys();
7315
- await this.refreshNodeEngineFromStore();
7316
7268
  this.pythonAddonDir = resolveAddonPythonDir();
7317
7269
  const py = await ensurePythonReady(this.ctx.deps, this.ctx.logger);
7318
7270
  if (py.ok && py.pythonPath) this.pythonPath = py.pythonPath;
@@ -7334,18 +7286,9 @@ var DetectionPipelineAddon = class extends BaseAddon {
7334
7286
  });
7335
7287
  await this.provider.init();
7336
7288
  await this.provider.setApi(this.ctx);
7337
- if (!this.nodeProbedBestEngine) await this.provider.reprobeEngine().catch((err) => {
7289
+ if (!this.config.probedBestEngine) await this.provider.reprobeEngine().catch((err) => {
7338
7290
  this.ctx.logger.warn("auto-reprobe engine failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7339
7291
  });
7340
- await this.provider.ensureBootEngineProvisioned().catch((err) => {
7341
- this.ctx.logger.warn("ensureBootEngineProvisioned failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7342
- });
7343
- this.probePhaseUnsub = this.ctx.eventBus?.subscribe({ category: EventCategory.PlatformProbePhase }, (event) => {
7344
- if (event.data?.phase !== "done") return;
7345
- this.provider?.repickEngineOnProbeReady().catch((err) => {
7346
- this.ctx.logger.warn("repick on platform-probe done failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7347
- });
7348
- }) ?? null;
7349
7292
  await this.provider.warmPool();
7350
7293
  this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
7351
7294
  this.lastAppliedPoolConfig = this.snapshotPoolConfig();
@@ -7419,10 +7362,6 @@ var DetectionPipelineAddon = class extends BaseAddon {
7419
7362
  }
7420
7363
  }
7421
7364
  async onShutdown() {
7422
- if (this.probePhaseUnsub) {
7423
- this.probePhaseUnsub();
7424
- this.probePhaseUnsub = null;
7425
- }
7426
7365
  if (this.engineMetricsTimer) {
7427
7366
  clearInterval(this.engineMetricsTimer);
7428
7367
  this.engineMetricsTimer = null;
@@ -7470,7 +7409,6 @@ var DetectionPipelineAddon = class extends BaseAddon {
7470
7409
  * re-spin, frames are dropped (never crashed), no addon bounce.
7471
7410
  */
7472
7411
  async onConfigChanged() {
7473
- await this.refreshNodeEngineFromStore();
7474
7412
  if (this.provider) await this.provider.onEngineSelectionChanged().catch((err) => {
7475
7413
  this.ctx.logger.warn("engine provisioning re-select failed on config change", { meta: { error: err instanceof Error ? err.message : String(err) } });
7476
7414
  });