@camstack/addon-pipeline 1.1.0 → 1.1.2

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 (34) hide show
  1. package/dist/audio-analyzer/index.js +104 -29
  2. package/dist/audio-analyzer/index.mjs +100 -25
  3. package/dist/audio-codec-nodeav/index.js +1 -1
  4. package/dist/audio-codec-nodeav/index.mjs +1 -1
  5. package/dist/decoder-nodeav/index.js +1 -1
  6. package/dist/decoder-nodeav/index.mjs +1 -1
  7. package/dist/detection-pipeline/index.js +355 -116
  8. package/dist/detection-pipeline/index.mjs +343 -104
  9. package/dist/{dist-BA6DR_jV.mjs → dist-BWc-HYQz.mjs} +194 -1
  10. package/dist/{dist-BLcTVvol.js → dist-DnD2tm7T.js} +194 -1
  11. package/dist/{model-download-service-RxAOiYvX-CMAvhgO7.mjs → model-download-service-C-IHWnXx-3Mmeob3l.mjs} +1 -1
  12. package/dist/{model-download-service-RxAOiYvX-C8rTRJy_.js → model-download-service-C-IHWnXx-BnQ_awK4.js} +1 -1
  13. package/dist/motion-wasm/index.js +1 -1
  14. package/dist/motion-wasm/index.mjs +1 -1
  15. package/dist/pipeline-runner/index.js +14 -10
  16. package/dist/pipeline-runner/index.mjs +14 -10
  17. package/dist/recorder/index.js +4 -4
  18. package/dist/recorder/index.mjs +2 -2
  19. package/dist/stream-broker/_stub.js +1 -1
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DrohyZ5L.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DLgk22-S.mjs} +3 -3
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-FRD2eBuz.mjs +26 -0
  22. package/dist/stream-broker/{hostInit-zLZbYJcg.mjs → hostInit-pRSjUAJj.mjs} +3 -3
  23. package/dist/stream-broker/index.js +8 -8
  24. package/dist/stream-broker/index.mjs +2 -2
  25. package/dist/stream-broker/remoteEntry.js +1 -1
  26. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-DJ7ztnFv.js → MaskShapeCanvas-DI4BY7W2-5UPreLSr.js} +1 -1
  27. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-CQzEnQoq.js → MotionZonesSettings-NcxxQN8r-Bxqs-CpZ.js} +1 -1
  28. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-Cl0eOy_U.js → PrivacyMaskSettings-APgPLF7p-BDMPeMJd.js} +1 -1
  29. package/embed-dist/assets/{index-CSuLwWK-.js → index-BgGwqHYl.js} +9 -9
  30. package/embed-dist/index.html +1 -1
  31. package/package.json +1 -1
  32. package/python/postprocessors/saliency.py +47 -1
  33. package/python/postprocessors/test_saliency.py +23 -0
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-COa17XL2.mjs +0 -26
@@ -2,17 +2,17 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_model_download_service_RxAOiYvX = require("../model-download-service-RxAOiYvX-C8rTRJy_.js");
6
- const require_dist = require("../dist-BLcTVvol.js");
5
+ const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-BnQ_awK4.js");
6
+ const require_dist = require("../dist-DnD2tm7T.js");
7
7
  let node_fs = require("node:fs");
8
- node_fs = require_model_download_service_RxAOiYvX.__toESM(node_fs);
8
+ node_fs = require_model_download_service_C_IHWnXx.__toESM(node_fs);
9
9
  let node_path = require("node:path");
10
- node_path = require_model_download_service_RxAOiYvX.__toESM(node_path);
10
+ node_path = require_model_download_service_C_IHWnXx.__toESM(node_path);
11
11
  let node_os = require("node:os");
12
- node_os = require_model_download_service_RxAOiYvX.__toESM(node_os);
12
+ node_os = require_model_download_service_C_IHWnXx.__toESM(node_os);
13
13
  let node_child_process = require("node:child_process");
14
14
  let sharp = require("sharp");
15
- sharp = require_model_download_service_RxAOiYvX.__toESM(sharp);
15
+ sharp = require_model_download_service_C_IHWnXx.__toESM(sharp);
16
16
  //#region src/detection-pipeline/engine-store-keys.ts
17
17
  /**
18
18
  * Per-node scoping for the detection-pipeline engine cascade.
@@ -121,7 +121,6 @@ function npuInfoFrom(hw) {
121
121
  return { type };
122
122
  }
123
123
  function envToHardwareInfo(env) {
124
- if (!env.hardware) return null;
125
124
  return {
126
125
  platform: toKnownPlatform(env.platform),
127
126
  arch: toKnownArch(env.arch),
@@ -129,8 +128,8 @@ function envToHardwareInfo(env) {
129
128
  cpuCores: 0,
130
129
  totalRAM_MB: 0,
131
130
  availableRAM_MB: 0,
132
- gpu: gpuInfoFrom(env.hardware),
133
- npu: npuInfoFrom(env.hardware)
131
+ gpu: env.hardware ? gpuInfoFrom(env.hardware) : null,
132
+ npu: env.hardware ? npuInfoFrom(env.hardware) : null
134
133
  };
135
134
  }
136
135
  function probedToHardwareInfo(hw) {
@@ -1043,6 +1042,26 @@ var ovFormat = (url, sizeMB) => {
1043
1042
  ...files ? { files } : {}
1044
1043
  };
1045
1044
  };
1045
+ /**
1046
+ * Build a precision-variant catalog entry (OpenVINO-only) derived from a base
1047
+ * detection model. fp16 halves the weights (Intel iGPU/NPU sweet spot); int8 is
1048
+ * NNCF post-training-quantized (~4× smaller, fastest on CPU/iGPU at a small
1049
+ * accuracy cost). The IRs live next to the base `.xml` on HF as
1050
+ * `camstack-<id>-<precision>.xml`. Lets an operator scale the model to the node
1051
+ * (e.g. yolo26x-int8 on a 265K, yolo26n-int8 on an N100).
1052
+ */
1053
+ var ovPrecisionVariant = (baseId, ovDir, baseName, precision, sizeMB) => ({
1054
+ id: `${baseId}-${precision}`,
1055
+ name: `${baseName} (${precision.toUpperCase()})`,
1056
+ description: `${baseName} — OpenVINO ${precision.toUpperCase()} variant for Intel iGPU/NPU; scale by hardware`,
1057
+ inputSize: {
1058
+ width: 640,
1059
+ height: 640
1060
+ },
1061
+ labels: [],
1062
+ preprocessMode: "letterbox",
1063
+ formats: { openvino: ovFormat(hf(`${ovDir}/camstack-${baseId}-${precision}.xml`), sizeMB) }
1064
+ });
1046
1065
  var MLPACKAGE_FILES = [
1047
1066
  "Manifest.json",
1048
1067
  "Data/com.apple.CoreML/model.mlmodel",
@@ -1348,7 +1367,21 @@ var OBJECT_DETECTION_MODELS = [
1348
1367
  },
1349
1368
  openvino: ovFormat(hf("objectDetection/scrypted-yolov9-relu/openvino/scrypted_yolov9m_relu.xml"), 38)
1350
1369
  }
1351
- }
1370
+ },
1371
+ ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "fp16", 5),
1372
+ ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "int8", 3),
1373
+ ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "fp16", 15),
1374
+ ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "int8", 8),
1375
+ ovPrecisionVariant("yolo26n", "objectDetection/yolo26/openvino", "YOLO26 Nano", "fp16", 5),
1376
+ ovPrecisionVariant("yolo26n", "objectDetection/yolo26/openvino", "YOLO26 Nano", "int8", 3),
1377
+ ovPrecisionVariant("yolo26s", "objectDetection/yolo26/openvino", "YOLO26 Small", "fp16", 19),
1378
+ ovPrecisionVariant("yolo26s", "objectDetection/yolo26/openvino", "YOLO26 Small", "int8", 10),
1379
+ ovPrecisionVariant("yolo26m", "objectDetection/yolo26/openvino", "YOLO26 Medium", "fp16", 41),
1380
+ ovPrecisionVariant("yolo26m", "objectDetection/yolo26/openvino", "YOLO26 Medium", "int8", 21),
1381
+ ovPrecisionVariant("yolo26l", "objectDetection/yolo26/openvino", "YOLO26 Large", "fp16", 50),
1382
+ ovPrecisionVariant("yolo26l", "objectDetection/yolo26/openvino", "YOLO26 Large", "int8", 25),
1383
+ ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "fp16", 112),
1384
+ ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "int8", 56)
1352
1385
  ];
1353
1386
  var FACE_DETECTION_MODELS = [{
1354
1387
  id: "scrfd-2.5g",
@@ -1795,7 +1828,7 @@ var ObjectDetectionStep = class {
1795
1828
  "animal"
1796
1829
  ],
1797
1830
  models: [...OBJECT_DETECTION_MODELS],
1798
- defaultModelId: "yolov9s",
1831
+ defaultModelId: "yolo26n",
1799
1832
  defaultConfidence: .5,
1800
1833
  labels: require_dist.COCO_80_LABELS.map((l) => l.id),
1801
1834
  classMap: require_dist.COCO_TO_MACRO
@@ -2044,9 +2077,9 @@ var STEP_VEHICLE_CLASSIFIER = new ClassifierWithMinConfidence({
2044
2077
  enabledByDefault: false,
2045
2078
  defaultConfidence: .3
2046
2079
  });
2047
- var STEP_SEGMENTATION_REFINER = new PipelineStepBase({
2048
- id: "segmentation-refiner",
2049
- name: "Saliency Segmentation",
2080
+ var STEP_SEGMENTATION = new PipelineStepBase({
2081
+ id: "segmentation",
2082
+ name: "Segmentation",
2050
2083
  slot: "refiner",
2051
2084
  postprocessor: "saliency",
2052
2085
  extractMode: "crop-roi",
@@ -2058,7 +2091,7 @@ var STEP_SEGMENTATION_REFINER = new PipelineStepBase({
2058
2091
  defaultConfidence: 0,
2059
2092
  group: "Segmentation"
2060
2093
  });
2061
- var STEP_INSTANCE_SEGMENTATION = new PipelineStepBase({
2094
+ new PipelineStepBase({
2062
2095
  id: "instance-segmentation",
2063
2096
  name: "Instance Segmentation",
2064
2097
  slot: "refiner",
@@ -2085,8 +2118,7 @@ var ALL_PIPELINE_STEPS = [
2085
2118
  new AnimalClassifierStep(),
2086
2119
  STEP_BIRD_CLASSIFIER,
2087
2120
  STEP_VEHICLE_CLASSIFIER,
2088
- STEP_SEGMENTATION_REFINER,
2089
- STEP_INSTANCE_SEGMENTATION,
2121
+ STEP_SEGMENTATION,
2090
2122
  STEP_AUDIO_CLASSIFIER_INSTANCE
2091
2123
  ];
2092
2124
  /** Compat: flat array of StepDefinition for existing consumers */
@@ -3257,6 +3289,18 @@ function applyChildOutput(parent, childStep, output, stepLatencyMs, ctx) {
3257
3289
  parent.mask = output.mask;
3258
3290
  parent.maskWidth = output.maskWidth;
3259
3291
  parent.maskHeight = output.maskHeight;
3292
+ if (output.maskBbox !== void 0 && output.maskWidth > 0 && output.maskHeight > 0) {
3293
+ const [px1, py1, px2, py2] = parent.bbox;
3294
+ const parentW = px2 - px1;
3295
+ const parentH = py2 - py1;
3296
+ const [mbx, mby, mbw, mbh] = output.maskBbox;
3297
+ parent.refinedBbox = [
3298
+ px1 + mbx / output.maskWidth * parentW,
3299
+ py1 + mby / output.maskHeight * parentH,
3300
+ px1 + (mbx + mbw) / output.maskWidth * parentW,
3301
+ py1 + (mby + mbh) / output.maskHeight * parentH
3302
+ ];
3303
+ }
3260
3304
  break;
3261
3305
  }
3262
3306
  }
@@ -3307,6 +3351,12 @@ function buildFrameResult(input) {
3307
3351
  macroClass: m.macroClass,
3308
3352
  score: m.score,
3309
3353
  bbox,
3354
+ ...m.refinedBbox !== void 0 ? { refinedBbox: bboxTupleToRect(m.refinedBbox) } : {},
3355
+ ...m.mask !== void 0 && m.maskWidth !== void 0 && m.maskHeight !== void 0 ? {
3356
+ mask: m.mask,
3357
+ maskWidth: m.maskWidth,
3358
+ maskHeight: m.maskHeight
3359
+ } : {},
3310
3360
  labels,
3311
3361
  ...m.parentId !== void 0 ? { parentId: m.parentId } : {},
3312
3362
  ...embedding !== void 0 ? {
@@ -3797,6 +3847,40 @@ function walkFieldsForDefaults(fields, out) {
3797
3847
  }
3798
3848
  }
3799
3849
  //#endregion
3850
+ //#region src/detection-pipeline/registry/custom-models.ts
3851
+ /**
3852
+ * Group a flat list of custom-model descriptors (as returned by the
3853
+ * `custom-model-registry` collection cap) into a `stepId → entries` map for
3854
+ * the picker / resolution union. Pure; order within a step preserved.
3855
+ */
3856
+ function groupCustomModelsByStep(descriptors) {
3857
+ const byStep = /* @__PURE__ */ new Map();
3858
+ for (const d of descriptors) {
3859
+ const arr = byStep.get(d.stepId) ?? [];
3860
+ arr.push(d.entry);
3861
+ byStep.set(d.stepId, arr);
3862
+ }
3863
+ return byStep;
3864
+ }
3865
+ /**
3866
+ * Union a step's static catalog models with operator-registered custom
3867
+ * models. On an `id` collision the static catalog entry wins — a custom
3868
+ * model can never shadow a built-in one.
3869
+ *
3870
+ * Pure + side-effect-free so it can be unit-tested in isolation and called
3871
+ * from the (free) `buildSchemaSlots` builder without any addon context.
3872
+ */
3873
+ function mergeCustomModels(staticModels, customModels) {
3874
+ const seen = new Set(staticModels.map((m) => m.id));
3875
+ const merged = [...staticModels];
3876
+ for (const m of customModels) {
3877
+ if (seen.has(m.id)) continue;
3878
+ seen.add(m.id);
3879
+ merged.push(m);
3880
+ }
3881
+ return merged;
3882
+ }
3883
+ //#endregion
3800
3884
  //#region src/detection-pipeline/provider.ts
3801
3885
  /**
3802
3886
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4011,6 +4095,17 @@ var ONNX_FLOOR = {
4011
4095
  * instead of duplicated inline.
4012
4096
  */
4013
4097
  function onnxFloorPick() {
4098
+ return ONNX_FLOOR;
4099
+ }
4100
+ /**
4101
+ * The platform-deterministic engine pick computed SYNCHRONOUSLY from this node's
4102
+ * own `process.platform`/`arch` alone (no probe): darwin → coreml, else → onnx
4103
+ * (gpu-dependent openvino/cuda need the probe and converge via the auto-pick).
4104
+ * Used as the ENGINE fallback when a persisted selection is unsupported on this
4105
+ * node — so a stale GLOBAL engine config (e.g. the cluster's OpenVINO choice)
4106
+ * can never force an impossible engine onto a node whose platform rejects it.
4107
+ */
4108
+ function platformDefaultPick() {
4014
4109
  const pick = pickBestRuntime(runtimeEnvFromProcess(null), null);
4015
4110
  return {
4016
4111
  runtime: "python",
@@ -4019,6 +4114,19 @@ function onnxFloorPick() {
4019
4114
  device: pick.device
4020
4115
  };
4021
4116
  }
4117
+ /**
4118
+ * Is `backend` even POSSIBLE on this node's OS/arch (ignoring gpu detail)?
4119
+ * coreml ⇒ darwin only; openvino ⇒ x64 non-darwin only; onnx ⇒ anywhere. Used to
4120
+ * reject a persisted/global engine choice that the node's PLATFORM fundamentally
4121
+ * cannot run (e.g. the cluster's OpenVINO default landing on a Mac) — distinct
4122
+ * from the gpu-dependent support (linux without a probed Intel iGPU still keeps
4123
+ * openvino as a valid platform choice; the device falls back to cpu).
4124
+ */
4125
+ function backendPossibleOnPlatform(backend) {
4126
+ if (backend === "coreml") return process.platform === "darwin";
4127
+ if (backend === "openvino") return process.arch === "x64" && process.platform !== "darwin";
4128
+ return true;
4129
+ }
4022
4130
  var DetectionPipelineProvider = class DetectionPipelineProvider {
4023
4131
  modelsDir;
4024
4132
  eventBus;
@@ -4032,6 +4140,13 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4032
4140
  /** Addon context — ctx.api resolves lazily (direct caller created after boot) */
4033
4141
  addonCtx = null;
4034
4142
  /**
4143
+ * Short-lived cache of custom models pulled from the `custom-model-registry`
4144
+ * collection cap, grouped by step id. TTL-bounded so the picker / resolution
4145
+ * paths don't issue a cap round-trip on every call. Empty map = no provider
4146
+ * (or a query failure) → behaviour identical to the static-catalog-only path.
4147
+ */
4148
+ customModelsCache = null;
4149
+ /**
4035
4150
  * Per-device {@link DeviceProxy} cache used for zone gating at the
4036
4151
  * runtime path. Reads `state.zones.value` + `state.zoneRules.value`
4037
4152
  * synchronously per frame so detections inside an `exclude` zone
@@ -4256,17 +4371,11 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4256
4371
  */
4257
4372
  async autoPickAndPersist() {
4258
4373
  let hardware = null;
4259
- let bestBackendHint = null;
4260
4374
  try {
4261
4375
  const api = this.addonCtx?.api;
4262
- if (api) {
4263
- const caps = await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() });
4264
- hardware = caps?.hardware ?? null;
4265
- const bs = caps?.bestScore;
4266
- if (bs && bs.runtime === "python") bestBackendHint = bs.backend;
4267
- }
4376
+ if (api) hardware = (await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() }))?.hardware ?? null;
4268
4377
  } catch {}
4269
- const pick = pickBestRuntime(runtimeEnvFromProcess(toProbedHardware(hardware)), bestBackendHint);
4378
+ const pick = pickBestRuntime(runtimeEnvFromProcess(toProbedHardware(hardware)), null);
4270
4379
  const engine = {
4271
4380
  runtime: "python",
4272
4381
  backend: pick.runtimeId,
@@ -4274,8 +4383,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4274
4383
  device: pick.device
4275
4384
  };
4276
4385
  this.currentEngine = engine;
4277
- if (!(hardware !== null || bestBackendHint !== null)) {
4278
- this.log.warn("Auto-pick: probe returned no hardware/hintusing onnx floor WITHOUT persisting", { meta: {
4386
+ if (!(pick.runtimeId !== "onnx" || hardware !== null)) {
4387
+ this.log.info("Auto-pick: onnx floor pending gpu probe NOT persisting (re-pick on done)", { meta: {
4279
4388
  backend: pick.runtimeId,
4280
4389
  device: pick.device
4281
4390
  } });
@@ -4286,10 +4395,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4286
4395
  [nodeEngineKey("engineBackend", apNode)]: pick.runtimeId,
4287
4396
  [nodeEngineKey("engineDevice", apNode)]: pick.device
4288
4397
  });
4289
- this.log.info("Auto-picked engine at first boot", { meta: {
4398
+ this.log.info("Auto-picked engine (platform-deterministic)", { meta: {
4290
4399
  backend: pick.runtimeId,
4291
4400
  device: pick.device,
4292
- hint: bestBackendHint ?? "none"
4401
+ hadProbeHardware: hardware !== null
4293
4402
  } });
4294
4403
  }
4295
4404
  /** Map a backend string to a known RuntimeId, flooring to onnx. */
@@ -4349,7 +4458,9 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4349
4458
  const runtimeId = this.toRuntimeId(engine.backend);
4350
4459
  const device = engine.device ?? "cpu";
4351
4460
  const snapshot = this.provisioner.state;
4352
- if (snapshot.runtimeId === runtimeId && snapshot.device === device && snapshot.state !== "idle") return;
4461
+ const sameSelection = snapshot.runtimeId === runtimeId && snapshot.device === device;
4462
+ if (sameSelection && snapshot.state !== "idle") return;
4463
+ if (!sameSelection) this.currentSteps = null;
4353
4464
  this.provisioner.select(runtimeId, device);
4354
4465
  }
4355
4466
  /**
@@ -4360,8 +4471,20 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4360
4471
  * runtime through installing → verifying → ready.
4361
4472
  */
4362
4473
  async onEngineSelectionChanged() {
4474
+ const prev = this.currentEngine;
4363
4475
  const stored = await this.loadEngine();
4364
4476
  if (stored) this.currentEngine = stored;
4477
+ if ((prev.runtime !== this.currentEngine.runtime || prev.backend !== this.currentEngine.backend || prev.format !== this.currentEngine.format || (prev.device ?? "") !== (this.currentEngine.device ?? "")) && this.engineFactory) {
4478
+ this.log.info("engine selection changed — rebuilding pool in place", { meta: {
4479
+ from: `${prev.backend}/${prev.device ?? "default"}`,
4480
+ to: `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`
4481
+ } });
4482
+ try {
4483
+ await this.engineFactory.dispose();
4484
+ } catch {}
4485
+ this.engineFactory = null;
4486
+ this.executor = null;
4487
+ }
4365
4488
  this.startProvisioningForCurrentEngine();
4366
4489
  }
4367
4490
  /**
@@ -4399,9 +4522,9 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4399
4522
  if (!steps || steps.length === 0) return;
4400
4523
  for (const step of flattenSteps(steps)) {
4401
4524
  if (!step.enabled) continue;
4402
- const modelEntry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
4525
+ const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
4403
4526
  if (!modelEntry) continue;
4404
- if (require_model_download_service_RxAOiYvX.isModelDownloaded(this.modelsDir, modelEntry, format)) continue;
4527
+ if (require_model_download_service_C_IHWnXx.isModelDownloaded(this.modelsDir, modelEntry, format)) continue;
4405
4528
  await this.downloadWithRetry(modelEntry, format, 3);
4406
4529
  }
4407
4530
  }
@@ -4477,10 +4600,44 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4477
4600
  return { hardware: null };
4478
4601
  }
4479
4602
  }
4603
+ /**
4604
+ * Pull custom models from the `custom-model-registry` collection cap,
4605
+ * grouped by step id. 5s TTL cache. Fully graceful: when no provider is
4606
+ * registered (or the query fails) it returns an empty map and logs at most
4607
+ * a warning — callers then behave exactly like the static-catalog path.
4608
+ */
4609
+ async getCustomModels() {
4610
+ const now = Date.now();
4611
+ if (this.customModelsCache && now - this.customModelsCache.at < 5e3) return this.customModelsCache.byStep;
4612
+ let byStep = /* @__PURE__ */ new Map();
4613
+ try {
4614
+ const api = this.addonCtx?.api;
4615
+ if (api) {
4616
+ if ((await api.addons.listCapabilityProviders.query({ capName: "custom-model-registry" })).some((p) => p.isActive)) byStep = groupCustomModelsByStep(await api.customModelRegistry.listModels.query());
4617
+ }
4618
+ } catch (err) {
4619
+ this.log.warn("custom-model-registry query failed — using static catalog only", { meta: { error: require_dist.errMsg(err) } });
4620
+ }
4621
+ this.customModelsCache = {
4622
+ at: now,
4623
+ byStep
4624
+ };
4625
+ return byStep;
4626
+ }
4627
+ /**
4628
+ * Resolve a model id within a step to a catalog entry — static catalog
4629
+ * first, then the custom registry. Returns undefined if neither has it.
4630
+ */
4631
+ async resolveModelEntry(addonId, modelId) {
4632
+ const fromCatalog = getStepDefinition(addonId).models.find((m) => m.id === modelId);
4633
+ if (fromCatalog) return fromCatalog;
4634
+ return (await this.getCustomModels()).get(addonId)?.find((m) => m.id === modelId);
4635
+ }
4480
4636
  async getSchema(engine) {
4481
4637
  if (!engine || !engine.runtime) engine = await this.getSelectedEngine();
4482
4638
  const format = engine.format;
4483
- const slots = buildSchemaSlots(format, this.modelsDir);
4639
+ const customByStep = await this.getCustomModels();
4640
+ const slots = buildSchemaSlots(format, this.modelsDir, customByStep);
4484
4641
  const { hardware } = await this.fetchProbeGatingData();
4485
4642
  const env = runtimeEnvFromProcess(toProbedHardware(hardware));
4486
4643
  return {
@@ -4539,13 +4696,52 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4539
4696
  async getDefaultSteps(engine) {
4540
4697
  return buildDefaultStepTree(engine.format);
4541
4698
  }
4699
+ /**
4700
+ * Substitute, AT RUNTIME ONLY (never persisted), any step whose configured
4701
+ * model lacks a build for that step's active engine format. A GLOBAL pipeline
4702
+ * config can pin a model that's valid on the cluster's Intel nodes (e.g. the
4703
+ * OpenVINO-only `yolov9t-int8`) but impossible on a CoreML/ONNX node — without
4704
+ * this the node crash-loops `Model "X" has no <format> format`. The operator's
4705
+ * choice stays in the persisted config; this node just runs the smallest
4706
+ * catalog model that DOES have its format. Mirrors the per-node engine
4707
+ * fallback in `loadEngine`. Catalog models only — a custom model that lacks
4708
+ * the format is left as-is (operator's responsibility).
4709
+ */
4710
+ substituteIncompatibleModels(steps) {
4711
+ const fix = (step) => {
4712
+ const format = step.engine?.format ?? this.currentEngine.format;
4713
+ let modelId = step.modelId;
4714
+ try {
4715
+ const entry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
4716
+ if (entry && !entry.formats[format]) {
4717
+ const fallback = getDefaultModelForFormat(step.addonId, format);
4718
+ if (fallback !== step.modelId) {
4719
+ this.log.info("Step model lacks engine format — substituting format default (runtime)", { meta: {
4720
+ step: step.addonId,
4721
+ configured: step.modelId,
4722
+ substitute: fallback,
4723
+ format
4724
+ } });
4725
+ modelId = fallback;
4726
+ }
4727
+ }
4728
+ } catch {}
4729
+ const children = step.children?.length ? step.children.map(fix) : step.children;
4730
+ return modelId === step.modelId && children === step.children ? step : {
4731
+ ...step,
4732
+ modelId,
4733
+ ...children ? { children } : {}
4734
+ };
4735
+ };
4736
+ return steps.map(fix);
4737
+ }
4542
4738
  async getGlobalSteps() {
4543
4739
  if (this.currentSteps) return this.currentSteps;
4544
4740
  const raw = (await this.readStore())[KEY_STEPS];
4545
4741
  if (!raw) {
4546
4742
  const defaults = buildDefaultStepTree(this.currentEngine.format);
4547
4743
  if (defaults.length === 0) return null;
4548
- this.currentSteps = defaults;
4744
+ this.currentSteps = this.substituteIncompatibleModels(defaults);
4549
4745
  this.writeStore({ [KEY_STEPS]: JSON.stringify(defaults) });
4550
4746
  this.log.info("Bootstrapped default pipeline — object-detection + face + plate recognition enabled by default", { meta: { rootSteps: defaults.length } });
4551
4747
  return this.currentSteps;
@@ -4583,7 +4779,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4583
4779
  this.log.info("Migration: added audio-classifier step to persisted pipeline config");
4584
4780
  }
4585
4781
  }
4586
- this.currentSteps = steps;
4782
+ this.currentSteps = this.substituteIncompatibleModels(steps);
4587
4783
  return this.currentSteps;
4588
4784
  } catch {
4589
4785
  throw new Error(`Failed to parse persisted pipeline steps: corrupt data in key "${KEY_STEPS}"`);
@@ -4609,7 +4805,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4609
4805
  const formats = {};
4610
4806
  for (const [formatKey, entry] of Object.entries(m.formats)) {
4611
4807
  if (!entry) continue;
4612
- const downloaded = require_model_download_service_RxAOiYvX.isModelDownloaded(this.modelsDir, m, formatKey);
4808
+ const downloaded = require_model_download_service_C_IHWnXx.isModelDownloaded(this.modelsDir, m, formatKey);
4613
4809
  formats[formatKey] = {
4614
4810
  url: entry.url,
4615
4811
  sizeMB: entry.sizeMB,
@@ -4667,7 +4863,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4667
4863
  }
4668
4864
  async downloadModel(input) {
4669
4865
  const { modelId, format, addonId } = input;
4670
- const modelEntry = getStepDefinition(addonId).models.find((m) => m.id === modelId);
4866
+ const modelEntry = await this.resolveModelEntry(addonId, modelId);
4671
4867
  if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${addonId}" catalog`);
4672
4868
  const formatEntry = modelEntry.formats[format];
4673
4869
  if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
@@ -4730,9 +4926,9 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4730
4926
  }
4731
4927
  async deleteModel(input) {
4732
4928
  const { modelId, format, addonId } = input;
4733
- const modelEntry = getStepDefinition(addonId).models.find((m) => m.id === modelId);
4929
+ const modelEntry = await this.resolveModelEntry(addonId, modelId);
4734
4930
  if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${addonId}" catalog`);
4735
- if (!require_model_download_service_RxAOiYvX.deleteModelFromDisk(this.modelsDir, modelEntry, format)) throw new Error(`Model "${modelId}" (${format}) is not downloaded — nothing to delete`);
4931
+ if (!require_model_download_service_C_IHWnXx.deleteModelFromDisk(this.modelsDir, modelEntry, format)) throw new Error(`Model "${modelId}" (${format}) is not downloaded — nothing to delete`);
4736
4932
  this.log.info("Model deleted from disk", { meta: {
4737
4933
  modelId,
4738
4934
  format
@@ -5186,8 +5382,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5186
5382
  const work = (async () => {
5187
5383
  const format = this.currentEngine?.format ?? "onnx";
5188
5384
  for (const step of needed) {
5189
- const modelEntry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
5190
- if (modelEntry && !require_model_download_service_RxAOiYvX.isModelDownloaded(this.modelsDir, modelEntry, format)) {
5385
+ const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
5386
+ if (modelEntry && !require_model_download_service_C_IHWnXx.isModelDownloaded(this.modelsDir, modelEntry, format)) {
5191
5387
  this.log.info("Downloading model for step", { meta: {
5192
5388
  modelId: step.modelId,
5193
5389
  format,
@@ -5212,7 +5408,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5212
5408
  /** Download a model with retry + exponential backoff */
5213
5409
  async downloadWithRetry(entry, format, maxRetries, onProgress) {
5214
5410
  for (let attempt = 1; attempt <= maxRetries; attempt++) try {
5215
- await require_model_download_service_RxAOiYvX.ensureModel(this.modelsDir, entry, format, onProgress);
5411
+ await require_model_download_service_C_IHWnXx.ensureModel(this.modelsDir, entry, format, onProgress);
5216
5412
  this.log.info("Model downloaded successfully", { meta: { modelId: entry.id } });
5217
5413
  return;
5218
5414
  } catch (err) {
@@ -5681,7 +5877,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5681
5877
  const format = this.currentEngine.format;
5682
5878
  const downloads = [];
5683
5879
  for (const step of flattenSteps(steps)) {
5684
- const modelEntry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
5880
+ const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
5685
5881
  if (!modelEntry) {
5686
5882
  this.log.warn("Model not found in step catalog — skipping download", { meta: {
5687
5883
  modelId: step.modelId,
@@ -5689,11 +5885,11 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5689
5885
  } });
5690
5886
  continue;
5691
5887
  }
5692
- if (!require_model_download_service_RxAOiYvX.isModelDownloaded(this.modelsDir, modelEntry, format)) this.log.info("Downloading model", { meta: {
5888
+ if (!require_model_download_service_C_IHWnXx.isModelDownloaded(this.modelsDir, modelEntry, format)) this.log.info("Downloading model", { meta: {
5693
5889
  modelId: step.modelId,
5694
5890
  format
5695
5891
  } });
5696
- downloads.push(require_model_download_service_RxAOiYvX.ensureModel(this.modelsDir, modelEntry, format).then(() => {}));
5892
+ downloads.push(require_model_download_service_C_IHWnXx.ensureModel(this.modelsDir, modelEntry, format).then(() => {}));
5697
5893
  }
5698
5894
  await Promise.all(downloads);
5699
5895
  await this.ensureBackendDeps(this.currentEngine);
@@ -5734,12 +5930,13 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5734
5930
  const storedDevice = typeof storedDeviceRaw === "string" ? storedDeviceRaw : "";
5735
5931
  const floor = onnxFloorPick();
5736
5932
  const migratedBackend = typeof storedRuntime === "string" && storedRuntime === "node" && backend === "cpu" ? "onnx" : backend;
5737
- if (!DetectionPipelineProvider.isPythonBackendAvailable(migratedBackend, this.executorOptions.pythonPath ?? "")) {
5738
- this.log.warn("Stored engine backend unavailable on this node — falling back to onnx floor", { meta: {
5933
+ if (!backendPossibleOnPlatform(migratedBackend)) {
5934
+ const platformDefault = platformDefaultPick();
5935
+ this.log.warn("Stored engine backend impossible on this platform — using platform default", { meta: {
5739
5936
  stored: migratedBackend,
5740
- fallback: `${floor.backend}`
5937
+ fallback: platformDefault.backend
5741
5938
  } });
5742
- return floor;
5939
+ return platformDefault;
5743
5940
  }
5744
5941
  const device = storedDevice || floor.device;
5745
5942
  return {
@@ -5911,44 +6108,61 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5911
6108
  }
5912
6109
  async reprobeEngine() {
5913
6110
  const api = this.addonCtx?.api;
5914
- let best;
6111
+ let hardware = null;
5915
6112
  if (api) try {
5916
- const caps = await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() });
5917
- const bs = caps?.bestScore;
5918
- if (bs && bs.runtime === "python") {
5919
- const probeBackend = bs.backend;
5920
- const probeDevice = (() => {
5921
- const hw = caps.hardware;
5922
- if (probeBackend === "openvino") return defaultDeviceFor("openvino");
5923
- if (probeBackend === "coreml") return defaultDeviceFor("coreml");
5924
- if (probeBackend === "onnx") return hw?.gpu?.type === "nvidia" ? "cuda" : "cpu";
5925
- return "cpu";
5926
- })();
5927
- best = {
5928
- runtime: "python",
5929
- backend: probeBackend,
5930
- format: backendToFormat(probeBackend),
5931
- device: probeDevice
5932
- };
5933
- } else best = onnxFloorPick();
5934
- } catch {
5935
- best = onnxFloorPick();
5936
- }
5937
- else best = onnxFloorPick();
6113
+ hardware = (await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() }))?.hardware ?? null;
6114
+ } catch {}
6115
+ const pick = pickBestRuntime(runtimeEnvFromProcess(toProbedHardware(hardware)), null);
6116
+ const best = {
6117
+ runtime: "python",
6118
+ backend: pick.runtimeId,
6119
+ format: modelFormatFor(pick.runtimeId),
6120
+ device: pick.device
6121
+ };
5938
6122
  const probedLabel = `${best.backend}/${best.device ?? "default"}`;
5939
6123
  const rpNode = this.localProbeNodeId();
5940
- await this.writeStore({
5941
- [nodeEngineKey("probedBestEngine", rpNode)]: probedLabel,
5942
- [nodeEngineKey("engineBackend", rpNode)]: best.backend,
5943
- [nodeEngineKey("engineDevice", rpNode)]: best.device ?? "cpu"
5944
- });
5945
- this.log.info("Re-probed engine — wrote back engineBackend + engineDevice", { meta: {
6124
+ if (pick.runtimeId !== "onnx" || hardware !== null) {
6125
+ await this.writeStore({
6126
+ [nodeEngineKey("probedBestEngine", rpNode)]: probedLabel,
6127
+ [nodeEngineKey("engineBackend", rpNode)]: best.backend,
6128
+ [nodeEngineKey("engineDevice", rpNode)]: best.device ?? "cpu"
6129
+ });
6130
+ this.log.info("Re-probed engine (platform-deterministic) — wrote back", { meta: {
6131
+ backend: best.backend,
6132
+ device: best.device ?? null,
6133
+ probedBestEngine: probedLabel
6134
+ } });
6135
+ } else this.log.info("Re-probe: onnx floor pending gpu probe — NOT persisting (re-pick on done)", { meta: {
5946
6136
  backend: best.backend,
5947
- device: best.device ?? null,
5948
- probedBestEngine: probedLabel
6137
+ device: best.device ?? null
5949
6138
  } });
5950
6139
  return best;
5951
6140
  }
6141
+ /**
6142
+ * Re-pick the engine when the platform-probe finishes its async hardware +
6143
+ * Python detection (the `platform-probe.phase` `done` event). At boot the
6144
+ * probe's accelerator result may not be ready yet, so the engine floored to
6145
+ * onnx; once the probe answers (e.g. a Mac's CoreML/ANE surfaces after the
6146
+ * embedded Python is installed) this re-runs the probe-driven pick and
6147
+ * re-provisions. Idempotent: `startProvisioningForCurrentEngine` skips a
6148
+ * no-op when the selection is unchanged.
6149
+ */
6150
+ async repickEngineOnProbeReady() {
6151
+ const before = `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`;
6152
+ await this.reprobeEngine();
6153
+ const stored = await this.loadEngine();
6154
+ if (stored) {
6155
+ this.currentEngine = stored;
6156
+ this.needsAutoPick = false;
6157
+ this.cancelDeferredAutoPick();
6158
+ }
6159
+ const after = `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`;
6160
+ if (before !== after) this.log.info("Engine re-picked after platform-probe completed", { meta: {
6161
+ before,
6162
+ after
6163
+ } });
6164
+ this.startProvisioningForCurrentEngine();
6165
+ }
5952
6166
  async getReferenceAudioFiles() {
5953
6167
  const dir = resolveReferenceAudioDir();
5954
6168
  if (!dir) return [];
@@ -6084,11 +6298,11 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6084
6298
  }
6085
6299
  }
6086
6300
  };
6087
- function buildSchemaSlots(format, modelsDir) {
6301
+ function buildSchemaSlots(format, modelsDir, customByStep) {
6088
6302
  const slotMap = /* @__PURE__ */ new Map();
6089
6303
  for (const pipelineStep of ALL_PIPELINE_STEPS) {
6090
6304
  const step = pipelineStep.definition;
6091
- const availableModels = step.models.filter((m) => m.formats[format]);
6305
+ const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.formats[format]);
6092
6306
  if (availableModels.length === 0) continue;
6093
6307
  const slot = step.slot;
6094
6308
  if (!slotMap.has(slot)) slotMap.set(slot, []);
@@ -6104,7 +6318,7 @@ function buildSchemaSlots(format, modelsDir) {
6104
6318
  id: m.id,
6105
6319
  name: m.name,
6106
6320
  formats: Object.fromEntries(Object.entries(m.formats).map(([f, entry]) => [f, {
6107
- downloaded: require_model_download_service_RxAOiYvX.isModelDownloaded(modelsDir, m, f),
6321
+ downloaded: require_model_download_service_C_IHWnXx.isModelDownloaded(modelsDir, m, f),
6108
6322
  sizeMB: entry.sizeMB
6109
6323
  }]))
6110
6324
  })),
@@ -6193,8 +6407,7 @@ function buildDefaultStepTree(format) {
6193
6407
  makeStep("animal-classifier", [], { enabled: false }),
6194
6408
  makeStep("bird-classifier", [], { enabled: false }),
6195
6409
  makeStep("vehicle-classifier", [], { enabled: false }),
6196
- makeStep("segmentation-refiner", [], { enabled: false }),
6197
- makeStep("instance-segmentation", [], { enabled: false })
6410
+ makeStep("segmentation", [], { enabled: false })
6198
6411
  ].filter((s) => s !== null));
6199
6412
  const audioEngine = getDefaultModelForFormat("audio-classifier", format) === "apple-soundanalysis" ? {
6200
6413
  runtime: "python",
@@ -6417,6 +6630,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6417
6630
  nodeEngineBackend = DEFAULT_CONFIG.engineBackend;
6418
6631
  nodeProbedBestEngine = "";
6419
6632
  engineMetricsTimer = null;
6633
+ probePhaseUnsub = null;
6420
6634
  /** Snapshot-equality cache for engine-metrics emit. Most ticks
6421
6635
  * the engine inventory is unchanged (no model load/unload), so
6422
6636
  * we skip the bus emit and let the heartbeat re-emit at
@@ -6484,8 +6698,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6484
6698
  label: "Execution provider",
6485
6699
  options: [...STATIC_BACKEND_OPTIONS],
6486
6700
  default: DEFAULT_CONFIG.engineBackend,
6487
- immediate: true,
6488
- requiresRestart: true
6701
+ immediate: true
6489
6702
  }),
6490
6703
  this.field({
6491
6704
  type: "select",
@@ -6493,8 +6706,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6493
6706
  label: "Hardware device",
6494
6707
  options: [...STATIC_DEFAULT_DEVICE_OPTIONS],
6495
6708
  default: DEFAULT_CONFIG.engineDevice,
6496
- immediate: true,
6497
- requiresRestart: true
6709
+ immediate: true
6498
6710
  })
6499
6711
  ]
6500
6712
  }, {
@@ -6533,8 +6745,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6533
6745
  "onnx",
6534
6746
  "cpu"
6535
6747
  ]
6536
- },
6537
- requiresRestart: true
6748
+ }
6538
6749
  }),
6539
6750
  this.field({
6540
6751
  type: "slider",
@@ -6551,8 +6762,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6551
6762
  showWhen: {
6552
6763
  field: "batchMode",
6553
6764
  notEquals: "none"
6554
- },
6555
- requiresRestart: true
6765
+ }
6556
6766
  }),
6557
6767
  this.field({
6558
6768
  type: "slider",
@@ -6569,8 +6779,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6569
6779
  showWhen: {
6570
6780
  field: "batchMode",
6571
6781
  notEquals: "none"
6572
- },
6573
- requiresRestart: true
6782
+ }
6574
6783
  }),
6575
6784
  this.field({
6576
6785
  type: "slider",
@@ -6583,8 +6792,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6583
6792
  default: void 0,
6584
6793
  showValue: true,
6585
6794
  nullable: true,
6586
- nullLabel: "Auto",
6587
- requiresRestart: true
6795
+ nullLabel: "Auto"
6588
6796
  }),
6589
6797
  this.field({
6590
6798
  type: "slider",
@@ -6597,8 +6805,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6597
6805
  default: void 0,
6598
6806
  showValue: true,
6599
6807
  nullable: true,
6600
- nullLabel: "Auto",
6601
- requiresRestart: true
6808
+ nullLabel: "Auto"
6602
6809
  })
6603
6810
  ]
6604
6811
  }] });
@@ -6830,11 +7037,35 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6830
7037
  numWorkers: num(t["numWorkers"], 1)
6831
7038
  };
6832
7039
  }
6833
- async onInitialize() {
6834
- const modelsDir = await this.ctx.api.storage.resolve.query({
7040
+ /**
7041
+ * Resolve the directory models are downloaded into, resilient across nodes.
7042
+ * The `models` storage location is GLOBAL (hub-seeded) and can resolve to a
7043
+ * path that exists only on the hub — e.g. Docker's `/data/models`, which an
7044
+ * agent on a different filesystem (a Mac) cannot create (`ENOENT mkdir
7045
+ * /data/models`). Verify the resolved dir is creatable; otherwise fall back to
7046
+ * this node's LOCAL addon data-dir so models always land on writable disk.
7047
+ */
7048
+ async resolveModelsDir() {
7049
+ const fallback = node_path.join(this.ctx.dataDir, "models");
7050
+ const candidate = await this.ctx.api.storage.resolve.query({
6835
7051
  location: "models",
6836
7052
  relativePath: ""
6837
- }).catch(() => "camstack-data/models");
7053
+ }).catch(() => null) ?? fallback;
7054
+ try {
7055
+ await node_fs.promises.mkdir(candidate, { recursive: true });
7056
+ return candidate;
7057
+ } catch (err) {
7058
+ this.ctx.logger.warn("models dir not creatable on this node — using local data-dir", { meta: {
7059
+ resolved: candidate,
7060
+ fallback,
7061
+ error: err instanceof Error ? err.message : String(err)
7062
+ } });
7063
+ await node_fs.promises.mkdir(fallback, { recursive: true });
7064
+ return fallback;
7065
+ }
7066
+ }
7067
+ async onInitialize() {
7068
+ const modelsDir = await this.resolveModelsDir();
6838
7069
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
6839
7070
  await this.refreshNodeEngineFromStore();
6840
7071
  this.pythonAddonDir = resolveAddonPythonDir();
@@ -6864,6 +7095,12 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6864
7095
  await this.provider.ensureBootEngineProvisioned().catch((err) => {
6865
7096
  this.ctx.logger.warn("ensureBootEngineProvisioned failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
6866
7097
  });
7098
+ this.probePhaseUnsub = this.ctx.eventBus?.subscribe({ category: require_dist.EventCategory.PlatformProbePhase }, (event) => {
7099
+ if (event.data?.phase !== "done") return;
7100
+ this.provider.repickEngineOnProbeReady().catch((err) => {
7101
+ this.ctx.logger.warn("repick on platform-probe done failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7102
+ });
7103
+ }) ?? null;
6867
7104
  await this.provider.warmPool();
6868
7105
  this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
6869
7106
  this.lastAppliedPoolConfig = this.snapshotPoolConfig();
@@ -6937,6 +7174,10 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6937
7174
  }
6938
7175
  }
6939
7176
  async onShutdown() {
7177
+ if (this.probePhaseUnsub) {
7178
+ this.probePhaseUnsub();
7179
+ this.probePhaseUnsub = null;
7180
+ }
6940
7181
  if (this.engineMetricsTimer) {
6941
7182
  clearInterval(this.engineMetricsTimer);
6942
7183
  this.engineMetricsTimer = null;
@@ -6971,16 +7212,17 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
6971
7212
  return false;
6972
7213
  }
6973
7214
  /**
6974
- * BaseAddon calls `onConfigChanged` after every settings write.
6975
- * Group-runner addons can't honour the schema's `requiresRestart:
6976
- * true` flag (the restart cap returns "process not found" for
6977
- * processes spawned in a kernel group worker). To make tuning
6978
- * changes actually take effect, watch the pool-bound subset and
6979
- * respawn the provider in place when it flips.
6980
- *
6981
- * Engine cascade (`engineRuntime/Backend/Device`) and audio
6982
- * settings don't require this those still rely on the addon
6983
- * lifecycle (engineFactory rebuild on next runPipeline).
7215
+ * BaseAddon calls `onConfigChanged` after every settings write. This is the
7216
+ * SOLE apply path for engine + tuning changes — none of those fields declare
7217
+ * `requiresRestart` anymore (an addon restart re-probes hardware + reloads
7218
+ * every model on every set, which is exactly what we want to avoid). Instead:
7219
+ * - Pool-bound tuning (`concurrency`/`batchMode`/`windowMs`/…) in-place
7220
+ * pool respawn when the snapshot flips (`poolConfigChanged` below).
7221
+ * - Engine cascade (`engineBackend`/`engineDevice`) → the provider's
7222
+ * `onEngineSelectionChanged` disposes the device-bound factory so the next
7223
+ * runPipeline rebuilds the pool on the new selection, in place.
7224
+ * Both apply optimistically the inference gate stays closed for the short
7225
+ * re-spin, frames are dropped (never crashed), no addon bounce.
6984
7226
  */
6985
7227
  async onConfigChanged() {
6986
7228
  await this.refreshNodeEngineFromStore();
@@ -7001,10 +7243,7 @@ var DetectionPipelineAddon = class extends require_dist.BaseAddon {
7001
7243
  } catch (err) {
7002
7244
  this.ctx.logger.warn("provider shutdown failed during tuning respawn", { meta: { error: err instanceof Error ? err.message : String(err) } });
7003
7245
  }
7004
- const modelsDir = await this.ctx.api.storage.resolve.query({
7005
- location: "models",
7006
- relativePath: ""
7007
- }).catch(() => "camstack-data/models");
7246
+ const modelsDir = await this.resolveModelsDir();
7008
7247
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available during respawn");
7009
7248
  const effectiveTuning = this.resolveBackendTuning();
7010
7249
  this.provider = new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {