@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
@@ -1,6 +1,6 @@
1
1
  import { t as __require } from "../chunk-BdkLduGY.mjs";
2
- import { A as nodePin, B as BaseAddon, C as detectionPipelineCapability, J as hydrateSchema, L as supportedRuntimes$1, P as runtimeDevices$1, T as hfModelUrl, W as EventCategory, Z as parseJsonUnknown, a as COCO_TO_MACRO, et as sleep, i as COCO_80_LABELS, j as pipelineExecutorCapability, p as YAMNET_TO_MACRO, q as createEvent, r as AUDIO_MACRO_LABELS, t as APPLE_SA_TO_MACRO, w as evaluateZoneRules, x as defaultDeviceFor$1, z as errMsg } from "../dist-BA6DR_jV.mjs";
3
- import { a as isModelDownloaded, i as ensureModel, n as deleteModelFromDisk } from "../model-download-service-RxAOiYvX-CMAvhgO7.mjs";
2
+ import { A as nodePin, B as BaseAddon, C as detectionPipelineCapability, J as hydrateSchema, L as supportedRuntimes$1, P as runtimeDevices$1, T as hfModelUrl, W as EventCategory, Z as parseJsonUnknown, a as COCO_TO_MACRO, et as sleep, i as COCO_80_LABELS, j as pipelineExecutorCapability, p as YAMNET_TO_MACRO, q as createEvent, r as AUDIO_MACRO_LABELS, t as APPLE_SA_TO_MACRO, w as evaluateZoneRules, x as defaultDeviceFor$1, z as errMsg } from "../dist-BWc-HYQz.mjs";
3
+ import { a as isModelDownloaded, i as ensureModel, n as deleteModelFromDisk } from "../model-download-service-C-IHWnXx-3Mmeob3l.mjs";
4
4
  import * as fs from "node:fs";
5
5
  import * as path$1 from "node:path";
6
6
  import * as os from "node:os";
@@ -114,7 +114,6 @@ function npuInfoFrom(hw) {
114
114
  return { type };
115
115
  }
116
116
  function envToHardwareInfo(env) {
117
- if (!env.hardware) return null;
118
117
  return {
119
118
  platform: toKnownPlatform(env.platform),
120
119
  arch: toKnownArch(env.arch),
@@ -122,8 +121,8 @@ function envToHardwareInfo(env) {
122
121
  cpuCores: 0,
123
122
  totalRAM_MB: 0,
124
123
  availableRAM_MB: 0,
125
- gpu: gpuInfoFrom(env.hardware),
126
- npu: npuInfoFrom(env.hardware)
124
+ gpu: env.hardware ? gpuInfoFrom(env.hardware) : null,
125
+ npu: env.hardware ? npuInfoFrom(env.hardware) : null
127
126
  };
128
127
  }
129
128
  function probedToHardwareInfo(hw) {
@@ -1036,6 +1035,26 @@ var ovFormat = (url, sizeMB) => {
1036
1035
  ...files ? { files } : {}
1037
1036
  };
1038
1037
  };
1038
+ /**
1039
+ * Build a precision-variant catalog entry (OpenVINO-only) derived from a base
1040
+ * detection model. fp16 halves the weights (Intel iGPU/NPU sweet spot); int8 is
1041
+ * NNCF post-training-quantized (~4× smaller, fastest on CPU/iGPU at a small
1042
+ * accuracy cost). The IRs live next to the base `.xml` on HF as
1043
+ * `camstack-<id>-<precision>.xml`. Lets an operator scale the model to the node
1044
+ * (e.g. yolo26x-int8 on a 265K, yolo26n-int8 on an N100).
1045
+ */
1046
+ var ovPrecisionVariant = (baseId, ovDir, baseName, precision, sizeMB) => ({
1047
+ id: `${baseId}-${precision}`,
1048
+ name: `${baseName} (${precision.toUpperCase()})`,
1049
+ description: `${baseName} — OpenVINO ${precision.toUpperCase()} variant for Intel iGPU/NPU; scale by hardware`,
1050
+ inputSize: {
1051
+ width: 640,
1052
+ height: 640
1053
+ },
1054
+ labels: [],
1055
+ preprocessMode: "letterbox",
1056
+ formats: { openvino: ovFormat(hf(`${ovDir}/camstack-${baseId}-${precision}.xml`), sizeMB) }
1057
+ });
1039
1058
  var MLPACKAGE_FILES = [
1040
1059
  "Manifest.json",
1041
1060
  "Data/com.apple.CoreML/model.mlmodel",
@@ -1341,7 +1360,21 @@ var OBJECT_DETECTION_MODELS = [
1341
1360
  },
1342
1361
  openvino: ovFormat(hf("objectDetection/scrypted-yolov9-relu/openvino/scrypted_yolov9m_relu.xml"), 38)
1343
1362
  }
1344
- }
1363
+ },
1364
+ ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "fp16", 5),
1365
+ ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "int8", 3),
1366
+ ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "fp16", 15),
1367
+ ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "int8", 8),
1368
+ ovPrecisionVariant("yolo26n", "objectDetection/yolo26/openvino", "YOLO26 Nano", "fp16", 5),
1369
+ ovPrecisionVariant("yolo26n", "objectDetection/yolo26/openvino", "YOLO26 Nano", "int8", 3),
1370
+ ovPrecisionVariant("yolo26s", "objectDetection/yolo26/openvino", "YOLO26 Small", "fp16", 19),
1371
+ ovPrecisionVariant("yolo26s", "objectDetection/yolo26/openvino", "YOLO26 Small", "int8", 10),
1372
+ ovPrecisionVariant("yolo26m", "objectDetection/yolo26/openvino", "YOLO26 Medium", "fp16", 41),
1373
+ ovPrecisionVariant("yolo26m", "objectDetection/yolo26/openvino", "YOLO26 Medium", "int8", 21),
1374
+ ovPrecisionVariant("yolo26l", "objectDetection/yolo26/openvino", "YOLO26 Large", "fp16", 50),
1375
+ ovPrecisionVariant("yolo26l", "objectDetection/yolo26/openvino", "YOLO26 Large", "int8", 25),
1376
+ ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "fp16", 112),
1377
+ ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "int8", 56)
1345
1378
  ];
1346
1379
  var FACE_DETECTION_MODELS = [{
1347
1380
  id: "scrfd-2.5g",
@@ -1788,7 +1821,7 @@ var ObjectDetectionStep = class {
1788
1821
  "animal"
1789
1822
  ],
1790
1823
  models: [...OBJECT_DETECTION_MODELS],
1791
- defaultModelId: "yolov9s",
1824
+ defaultModelId: "yolo26n",
1792
1825
  defaultConfidence: .5,
1793
1826
  labels: COCO_80_LABELS.map((l) => l.id),
1794
1827
  classMap: COCO_TO_MACRO
@@ -2037,9 +2070,9 @@ var STEP_VEHICLE_CLASSIFIER = new ClassifierWithMinConfidence({
2037
2070
  enabledByDefault: false,
2038
2071
  defaultConfidence: .3
2039
2072
  });
2040
- var STEP_SEGMENTATION_REFINER = new PipelineStepBase({
2041
- id: "segmentation-refiner",
2042
- name: "Saliency Segmentation",
2073
+ var STEP_SEGMENTATION = new PipelineStepBase({
2074
+ id: "segmentation",
2075
+ name: "Segmentation",
2043
2076
  slot: "refiner",
2044
2077
  postprocessor: "saliency",
2045
2078
  extractMode: "crop-roi",
@@ -2051,7 +2084,7 @@ var STEP_SEGMENTATION_REFINER = new PipelineStepBase({
2051
2084
  defaultConfidence: 0,
2052
2085
  group: "Segmentation"
2053
2086
  });
2054
- var STEP_INSTANCE_SEGMENTATION = new PipelineStepBase({
2087
+ new PipelineStepBase({
2055
2088
  id: "instance-segmentation",
2056
2089
  name: "Instance Segmentation",
2057
2090
  slot: "refiner",
@@ -2078,8 +2111,7 @@ var ALL_PIPELINE_STEPS = [
2078
2111
  new AnimalClassifierStep(),
2079
2112
  STEP_BIRD_CLASSIFIER,
2080
2113
  STEP_VEHICLE_CLASSIFIER,
2081
- STEP_SEGMENTATION_REFINER,
2082
- STEP_INSTANCE_SEGMENTATION,
2114
+ STEP_SEGMENTATION,
2083
2115
  STEP_AUDIO_CLASSIFIER_INSTANCE
2084
2116
  ];
2085
2117
  /** Compat: flat array of StepDefinition for existing consumers */
@@ -3250,6 +3282,18 @@ function applyChildOutput(parent, childStep, output, stepLatencyMs, ctx) {
3250
3282
  parent.mask = output.mask;
3251
3283
  parent.maskWidth = output.maskWidth;
3252
3284
  parent.maskHeight = output.maskHeight;
3285
+ if (output.maskBbox !== void 0 && output.maskWidth > 0 && output.maskHeight > 0) {
3286
+ const [px1, py1, px2, py2] = parent.bbox;
3287
+ const parentW = px2 - px1;
3288
+ const parentH = py2 - py1;
3289
+ const [mbx, mby, mbw, mbh] = output.maskBbox;
3290
+ parent.refinedBbox = [
3291
+ px1 + mbx / output.maskWidth * parentW,
3292
+ py1 + mby / output.maskHeight * parentH,
3293
+ px1 + (mbx + mbw) / output.maskWidth * parentW,
3294
+ py1 + (mby + mbh) / output.maskHeight * parentH
3295
+ ];
3296
+ }
3253
3297
  break;
3254
3298
  }
3255
3299
  }
@@ -3300,6 +3344,12 @@ function buildFrameResult(input) {
3300
3344
  macroClass: m.macroClass,
3301
3345
  score: m.score,
3302
3346
  bbox,
3347
+ ...m.refinedBbox !== void 0 ? { refinedBbox: bboxTupleToRect(m.refinedBbox) } : {},
3348
+ ...m.mask !== void 0 && m.maskWidth !== void 0 && m.maskHeight !== void 0 ? {
3349
+ mask: m.mask,
3350
+ maskWidth: m.maskWidth,
3351
+ maskHeight: m.maskHeight
3352
+ } : {},
3303
3353
  labels,
3304
3354
  ...m.parentId !== void 0 ? { parentId: m.parentId } : {},
3305
3355
  ...embedding !== void 0 ? {
@@ -3790,6 +3840,40 @@ function walkFieldsForDefaults(fields, out) {
3790
3840
  }
3791
3841
  }
3792
3842
  //#endregion
3843
+ //#region src/detection-pipeline/registry/custom-models.ts
3844
+ /**
3845
+ * Group a flat list of custom-model descriptors (as returned by the
3846
+ * `custom-model-registry` collection cap) into a `stepId → entries` map for
3847
+ * the picker / resolution union. Pure; order within a step preserved.
3848
+ */
3849
+ function groupCustomModelsByStep(descriptors) {
3850
+ const byStep = /* @__PURE__ */ new Map();
3851
+ for (const d of descriptors) {
3852
+ const arr = byStep.get(d.stepId) ?? [];
3853
+ arr.push(d.entry);
3854
+ byStep.set(d.stepId, arr);
3855
+ }
3856
+ return byStep;
3857
+ }
3858
+ /**
3859
+ * Union a step's static catalog models with operator-registered custom
3860
+ * models. On an `id` collision the static catalog entry wins — a custom
3861
+ * model can never shadow a built-in one.
3862
+ *
3863
+ * Pure + side-effect-free so it can be unit-tested in isolation and called
3864
+ * from the (free) `buildSchemaSlots` builder without any addon context.
3865
+ */
3866
+ function mergeCustomModels(staticModels, customModels) {
3867
+ const seen = new Set(staticModels.map((m) => m.id));
3868
+ const merged = [...staticModels];
3869
+ for (const m of customModels) {
3870
+ if (seen.has(m.id)) continue;
3871
+ seen.add(m.id);
3872
+ merged.push(m);
3873
+ }
3874
+ return merged;
3875
+ }
3876
+ //#endregion
3793
3877
  //#region src/detection-pipeline/provider.ts
3794
3878
  /**
3795
3879
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4004,6 +4088,17 @@ var ONNX_FLOOR = {
4004
4088
  * instead of duplicated inline.
4005
4089
  */
4006
4090
  function onnxFloorPick() {
4091
+ return ONNX_FLOOR;
4092
+ }
4093
+ /**
4094
+ * The platform-deterministic engine pick computed SYNCHRONOUSLY from this node's
4095
+ * own `process.platform`/`arch` alone (no probe): darwin → coreml, else → onnx
4096
+ * (gpu-dependent openvino/cuda need the probe and converge via the auto-pick).
4097
+ * Used as the ENGINE fallback when a persisted selection is unsupported on this
4098
+ * node — so a stale GLOBAL engine config (e.g. the cluster's OpenVINO choice)
4099
+ * can never force an impossible engine onto a node whose platform rejects it.
4100
+ */
4101
+ function platformDefaultPick() {
4007
4102
  const pick = pickBestRuntime(runtimeEnvFromProcess(null), null);
4008
4103
  return {
4009
4104
  runtime: "python",
@@ -4012,6 +4107,19 @@ function onnxFloorPick() {
4012
4107
  device: pick.device
4013
4108
  };
4014
4109
  }
4110
+ /**
4111
+ * Is `backend` even POSSIBLE on this node's OS/arch (ignoring gpu detail)?
4112
+ * coreml ⇒ darwin only; openvino ⇒ x64 non-darwin only; onnx ⇒ anywhere. Used to
4113
+ * reject a persisted/global engine choice that the node's PLATFORM fundamentally
4114
+ * cannot run (e.g. the cluster's OpenVINO default landing on a Mac) — distinct
4115
+ * from the gpu-dependent support (linux without a probed Intel iGPU still keeps
4116
+ * openvino as a valid platform choice; the device falls back to cpu).
4117
+ */
4118
+ function backendPossibleOnPlatform(backend) {
4119
+ if (backend === "coreml") return process.platform === "darwin";
4120
+ if (backend === "openvino") return process.arch === "x64" && process.platform !== "darwin";
4121
+ return true;
4122
+ }
4015
4123
  var DetectionPipelineProvider = class DetectionPipelineProvider {
4016
4124
  modelsDir;
4017
4125
  eventBus;
@@ -4025,6 +4133,13 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4025
4133
  /** Addon context — ctx.api resolves lazily (direct caller created after boot) */
4026
4134
  addonCtx = null;
4027
4135
  /**
4136
+ * Short-lived cache of custom models pulled from the `custom-model-registry`
4137
+ * collection cap, grouped by step id. TTL-bounded so the picker / resolution
4138
+ * paths don't issue a cap round-trip on every call. Empty map = no provider
4139
+ * (or a query failure) → behaviour identical to the static-catalog-only path.
4140
+ */
4141
+ customModelsCache = null;
4142
+ /**
4028
4143
  * Per-device {@link DeviceProxy} cache used for zone gating at the
4029
4144
  * runtime path. Reads `state.zones.value` + `state.zoneRules.value`
4030
4145
  * synchronously per frame so detections inside an `exclude` zone
@@ -4249,17 +4364,11 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4249
4364
  */
4250
4365
  async autoPickAndPersist() {
4251
4366
  let hardware = null;
4252
- let bestBackendHint = null;
4253
4367
  try {
4254
4368
  const api = this.addonCtx?.api;
4255
- if (api) {
4256
- const caps = await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() });
4257
- hardware = caps?.hardware ?? null;
4258
- const bs = caps?.bestScore;
4259
- if (bs && bs.runtime === "python") bestBackendHint = bs.backend;
4260
- }
4369
+ if (api) hardware = (await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() }))?.hardware ?? null;
4261
4370
  } catch {}
4262
- const pick = pickBestRuntime(runtimeEnvFromProcess(toProbedHardware(hardware)), bestBackendHint);
4371
+ const pick = pickBestRuntime(runtimeEnvFromProcess(toProbedHardware(hardware)), null);
4263
4372
  const engine = {
4264
4373
  runtime: "python",
4265
4374
  backend: pick.runtimeId,
@@ -4267,8 +4376,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4267
4376
  device: pick.device
4268
4377
  };
4269
4378
  this.currentEngine = engine;
4270
- if (!(hardware !== null || bestBackendHint !== null)) {
4271
- this.log.warn("Auto-pick: probe returned no hardware/hintusing onnx floor WITHOUT persisting", { meta: {
4379
+ if (!(pick.runtimeId !== "onnx" || hardware !== null)) {
4380
+ this.log.info("Auto-pick: onnx floor pending gpu probe NOT persisting (re-pick on done)", { meta: {
4272
4381
  backend: pick.runtimeId,
4273
4382
  device: pick.device
4274
4383
  } });
@@ -4279,10 +4388,10 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4279
4388
  [nodeEngineKey("engineBackend", apNode)]: pick.runtimeId,
4280
4389
  [nodeEngineKey("engineDevice", apNode)]: pick.device
4281
4390
  });
4282
- this.log.info("Auto-picked engine at first boot", { meta: {
4391
+ this.log.info("Auto-picked engine (platform-deterministic)", { meta: {
4283
4392
  backend: pick.runtimeId,
4284
4393
  device: pick.device,
4285
- hint: bestBackendHint ?? "none"
4394
+ hadProbeHardware: hardware !== null
4286
4395
  } });
4287
4396
  }
4288
4397
  /** Map a backend string to a known RuntimeId, flooring to onnx. */
@@ -4342,7 +4451,9 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4342
4451
  const runtimeId = this.toRuntimeId(engine.backend);
4343
4452
  const device = engine.device ?? "cpu";
4344
4453
  const snapshot = this.provisioner.state;
4345
- if (snapshot.runtimeId === runtimeId && snapshot.device === device && snapshot.state !== "idle") return;
4454
+ const sameSelection = snapshot.runtimeId === runtimeId && snapshot.device === device;
4455
+ if (sameSelection && snapshot.state !== "idle") return;
4456
+ if (!sameSelection) this.currentSteps = null;
4346
4457
  this.provisioner.select(runtimeId, device);
4347
4458
  }
4348
4459
  /**
@@ -4353,8 +4464,20 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4353
4464
  * runtime through installing → verifying → ready.
4354
4465
  */
4355
4466
  async onEngineSelectionChanged() {
4467
+ const prev = this.currentEngine;
4356
4468
  const stored = await this.loadEngine();
4357
4469
  if (stored) this.currentEngine = stored;
4470
+ if ((prev.runtime !== this.currentEngine.runtime || prev.backend !== this.currentEngine.backend || prev.format !== this.currentEngine.format || (prev.device ?? "") !== (this.currentEngine.device ?? "")) && this.engineFactory) {
4471
+ this.log.info("engine selection changed — rebuilding pool in place", { meta: {
4472
+ from: `${prev.backend}/${prev.device ?? "default"}`,
4473
+ to: `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`
4474
+ } });
4475
+ try {
4476
+ await this.engineFactory.dispose();
4477
+ } catch {}
4478
+ this.engineFactory = null;
4479
+ this.executor = null;
4480
+ }
4358
4481
  this.startProvisioningForCurrentEngine();
4359
4482
  }
4360
4483
  /**
@@ -4392,7 +4515,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4392
4515
  if (!steps || steps.length === 0) return;
4393
4516
  for (const step of flattenSteps(steps)) {
4394
4517
  if (!step.enabled) continue;
4395
- const modelEntry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
4518
+ const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
4396
4519
  if (!modelEntry) continue;
4397
4520
  if (isModelDownloaded(this.modelsDir, modelEntry, format)) continue;
4398
4521
  await this.downloadWithRetry(modelEntry, format, 3);
@@ -4470,10 +4593,44 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4470
4593
  return { hardware: null };
4471
4594
  }
4472
4595
  }
4596
+ /**
4597
+ * Pull custom models from the `custom-model-registry` collection cap,
4598
+ * grouped by step id. 5s TTL cache. Fully graceful: when no provider is
4599
+ * registered (or the query fails) it returns an empty map and logs at most
4600
+ * a warning — callers then behave exactly like the static-catalog path.
4601
+ */
4602
+ async getCustomModels() {
4603
+ const now = Date.now();
4604
+ if (this.customModelsCache && now - this.customModelsCache.at < 5e3) return this.customModelsCache.byStep;
4605
+ let byStep = /* @__PURE__ */ new Map();
4606
+ try {
4607
+ const api = this.addonCtx?.api;
4608
+ if (api) {
4609
+ if ((await api.addons.listCapabilityProviders.query({ capName: "custom-model-registry" })).some((p) => p.isActive)) byStep = groupCustomModelsByStep(await api.customModelRegistry.listModels.query());
4610
+ }
4611
+ } catch (err) {
4612
+ this.log.warn("custom-model-registry query failed — using static catalog only", { meta: { error: errMsg(err) } });
4613
+ }
4614
+ this.customModelsCache = {
4615
+ at: now,
4616
+ byStep
4617
+ };
4618
+ return byStep;
4619
+ }
4620
+ /**
4621
+ * Resolve a model id within a step to a catalog entry — static catalog
4622
+ * first, then the custom registry. Returns undefined if neither has it.
4623
+ */
4624
+ async resolveModelEntry(addonId, modelId) {
4625
+ const fromCatalog = getStepDefinition(addonId).models.find((m) => m.id === modelId);
4626
+ if (fromCatalog) return fromCatalog;
4627
+ return (await this.getCustomModels()).get(addonId)?.find((m) => m.id === modelId);
4628
+ }
4473
4629
  async getSchema(engine) {
4474
4630
  if (!engine || !engine.runtime) engine = await this.getSelectedEngine();
4475
4631
  const format = engine.format;
4476
- const slots = buildSchemaSlots(format, this.modelsDir);
4632
+ const customByStep = await this.getCustomModels();
4633
+ const slots = buildSchemaSlots(format, this.modelsDir, customByStep);
4477
4634
  const { hardware } = await this.fetchProbeGatingData();
4478
4635
  const env = runtimeEnvFromProcess(toProbedHardware(hardware));
4479
4636
  return {
@@ -4532,13 +4689,52 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4532
4689
  async getDefaultSteps(engine) {
4533
4690
  return buildDefaultStepTree(engine.format);
4534
4691
  }
4692
+ /**
4693
+ * Substitute, AT RUNTIME ONLY (never persisted), any step whose configured
4694
+ * model lacks a build for that step's active engine format. A GLOBAL pipeline
4695
+ * config can pin a model that's valid on the cluster's Intel nodes (e.g. the
4696
+ * OpenVINO-only `yolov9t-int8`) but impossible on a CoreML/ONNX node — without
4697
+ * this the node crash-loops `Model "X" has no <format> format`. The operator's
4698
+ * choice stays in the persisted config; this node just runs the smallest
4699
+ * catalog model that DOES have its format. Mirrors the per-node engine
4700
+ * fallback in `loadEngine`. Catalog models only — a custom model that lacks
4701
+ * the format is left as-is (operator's responsibility).
4702
+ */
4703
+ substituteIncompatibleModels(steps) {
4704
+ const fix = (step) => {
4705
+ const format = step.engine?.format ?? this.currentEngine.format;
4706
+ let modelId = step.modelId;
4707
+ try {
4708
+ const entry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
4709
+ if (entry && !entry.formats[format]) {
4710
+ const fallback = getDefaultModelForFormat(step.addonId, format);
4711
+ if (fallback !== step.modelId) {
4712
+ this.log.info("Step model lacks engine format — substituting format default (runtime)", { meta: {
4713
+ step: step.addonId,
4714
+ configured: step.modelId,
4715
+ substitute: fallback,
4716
+ format
4717
+ } });
4718
+ modelId = fallback;
4719
+ }
4720
+ }
4721
+ } catch {}
4722
+ const children = step.children?.length ? step.children.map(fix) : step.children;
4723
+ return modelId === step.modelId && children === step.children ? step : {
4724
+ ...step,
4725
+ modelId,
4726
+ ...children ? { children } : {}
4727
+ };
4728
+ };
4729
+ return steps.map(fix);
4730
+ }
4535
4731
  async getGlobalSteps() {
4536
4732
  if (this.currentSteps) return this.currentSteps;
4537
4733
  const raw = (await this.readStore())[KEY_STEPS];
4538
4734
  if (!raw) {
4539
4735
  const defaults = buildDefaultStepTree(this.currentEngine.format);
4540
4736
  if (defaults.length === 0) return null;
4541
- this.currentSteps = defaults;
4737
+ this.currentSteps = this.substituteIncompatibleModels(defaults);
4542
4738
  this.writeStore({ [KEY_STEPS]: JSON.stringify(defaults) });
4543
4739
  this.log.info("Bootstrapped default pipeline — object-detection + face + plate recognition enabled by default", { meta: { rootSteps: defaults.length } });
4544
4740
  return this.currentSteps;
@@ -4576,7 +4772,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4576
4772
  this.log.info("Migration: added audio-classifier step to persisted pipeline config");
4577
4773
  }
4578
4774
  }
4579
- this.currentSteps = steps;
4775
+ this.currentSteps = this.substituteIncompatibleModels(steps);
4580
4776
  return this.currentSteps;
4581
4777
  } catch {
4582
4778
  throw new Error(`Failed to parse persisted pipeline steps: corrupt data in key "${KEY_STEPS}"`);
@@ -4660,7 +4856,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4660
4856
  }
4661
4857
  async downloadModel(input) {
4662
4858
  const { modelId, format, addonId } = input;
4663
- const modelEntry = getStepDefinition(addonId).models.find((m) => m.id === modelId);
4859
+ const modelEntry = await this.resolveModelEntry(addonId, modelId);
4664
4860
  if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${addonId}" catalog`);
4665
4861
  const formatEntry = modelEntry.formats[format];
4666
4862
  if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
@@ -4723,7 +4919,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4723
4919
  }
4724
4920
  async deleteModel(input) {
4725
4921
  const { modelId, format, addonId } = input;
4726
- const modelEntry = getStepDefinition(addonId).models.find((m) => m.id === modelId);
4922
+ const modelEntry = await this.resolveModelEntry(addonId, modelId);
4727
4923
  if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${addonId}" catalog`);
4728
4924
  if (!deleteModelFromDisk(this.modelsDir, modelEntry, format)) throw new Error(`Model "${modelId}" (${format}) is not downloaded — nothing to delete`);
4729
4925
  this.log.info("Model deleted from disk", { meta: {
@@ -5179,7 +5375,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5179
5375
  const work = (async () => {
5180
5376
  const format = this.currentEngine?.format ?? "onnx";
5181
5377
  for (const step of needed) {
5182
- const modelEntry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
5378
+ const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
5183
5379
  if (modelEntry && !isModelDownloaded(this.modelsDir, modelEntry, format)) {
5184
5380
  this.log.info("Downloading model for step", { meta: {
5185
5381
  modelId: step.modelId,
@@ -5674,7 +5870,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5674
5870
  const format = this.currentEngine.format;
5675
5871
  const downloads = [];
5676
5872
  for (const step of flattenSteps(steps)) {
5677
- const modelEntry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
5873
+ const modelEntry = await this.resolveModelEntry(step.addonId, step.modelId);
5678
5874
  if (!modelEntry) {
5679
5875
  this.log.warn("Model not found in step catalog — skipping download", { meta: {
5680
5876
  modelId: step.modelId,
@@ -5727,12 +5923,13 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5727
5923
  const storedDevice = typeof storedDeviceRaw === "string" ? storedDeviceRaw : "";
5728
5924
  const floor = onnxFloorPick();
5729
5925
  const migratedBackend = typeof storedRuntime === "string" && storedRuntime === "node" && backend === "cpu" ? "onnx" : backend;
5730
- if (!DetectionPipelineProvider.isPythonBackendAvailable(migratedBackend, this.executorOptions.pythonPath ?? "")) {
5731
- this.log.warn("Stored engine backend unavailable on this node — falling back to onnx floor", { meta: {
5926
+ if (!backendPossibleOnPlatform(migratedBackend)) {
5927
+ const platformDefault = platformDefaultPick();
5928
+ this.log.warn("Stored engine backend impossible on this platform — using platform default", { meta: {
5732
5929
  stored: migratedBackend,
5733
- fallback: `${floor.backend}`
5930
+ fallback: platformDefault.backend
5734
5931
  } });
5735
- return floor;
5932
+ return platformDefault;
5736
5933
  }
5737
5934
  const device = storedDevice || floor.device;
5738
5935
  return {
@@ -5904,44 +6101,61 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5904
6101
  }
5905
6102
  async reprobeEngine() {
5906
6103
  const api = this.addonCtx?.api;
5907
- let best;
6104
+ let hardware = null;
5908
6105
  if (api) try {
5909
- const caps = await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() });
5910
- const bs = caps?.bestScore;
5911
- if (bs && bs.runtime === "python") {
5912
- const probeBackend = bs.backend;
5913
- const probeDevice = (() => {
5914
- const hw = caps.hardware;
5915
- if (probeBackend === "openvino") return defaultDeviceFor("openvino");
5916
- if (probeBackend === "coreml") return defaultDeviceFor("coreml");
5917
- if (probeBackend === "onnx") return hw?.gpu?.type === "nvidia" ? "cuda" : "cpu";
5918
- return "cpu";
5919
- })();
5920
- best = {
5921
- runtime: "python",
5922
- backend: probeBackend,
5923
- format: backendToFormat(probeBackend),
5924
- device: probeDevice
5925
- };
5926
- } else best = onnxFloorPick();
5927
- } catch {
5928
- best = onnxFloorPick();
5929
- }
5930
- else best = onnxFloorPick();
6106
+ hardware = (await api.platformProbe.getCapabilities.query({ nodeId: this.localProbeNodeId() }))?.hardware ?? null;
6107
+ } catch {}
6108
+ const pick = pickBestRuntime(runtimeEnvFromProcess(toProbedHardware(hardware)), null);
6109
+ const best = {
6110
+ runtime: "python",
6111
+ backend: pick.runtimeId,
6112
+ format: modelFormatFor(pick.runtimeId),
6113
+ device: pick.device
6114
+ };
5931
6115
  const probedLabel = `${best.backend}/${best.device ?? "default"}`;
5932
6116
  const rpNode = this.localProbeNodeId();
5933
- await this.writeStore({
5934
- [nodeEngineKey("probedBestEngine", rpNode)]: probedLabel,
5935
- [nodeEngineKey("engineBackend", rpNode)]: best.backend,
5936
- [nodeEngineKey("engineDevice", rpNode)]: best.device ?? "cpu"
5937
- });
5938
- this.log.info("Re-probed engine — wrote back engineBackend + engineDevice", { meta: {
6117
+ if (pick.runtimeId !== "onnx" || hardware !== null) {
6118
+ await this.writeStore({
6119
+ [nodeEngineKey("probedBestEngine", rpNode)]: probedLabel,
6120
+ [nodeEngineKey("engineBackend", rpNode)]: best.backend,
6121
+ [nodeEngineKey("engineDevice", rpNode)]: best.device ?? "cpu"
6122
+ });
6123
+ this.log.info("Re-probed engine (platform-deterministic) — wrote back", { meta: {
6124
+ backend: best.backend,
6125
+ device: best.device ?? null,
6126
+ probedBestEngine: probedLabel
6127
+ } });
6128
+ } else this.log.info("Re-probe: onnx floor pending gpu probe — NOT persisting (re-pick on done)", { meta: {
5939
6129
  backend: best.backend,
5940
- device: best.device ?? null,
5941
- probedBestEngine: probedLabel
6130
+ device: best.device ?? null
5942
6131
  } });
5943
6132
  return best;
5944
6133
  }
6134
+ /**
6135
+ * Re-pick the engine when the platform-probe finishes its async hardware +
6136
+ * Python detection (the `platform-probe.phase` `done` event). At boot the
6137
+ * probe's accelerator result may not be ready yet, so the engine floored to
6138
+ * onnx; once the probe answers (e.g. a Mac's CoreML/ANE surfaces after the
6139
+ * embedded Python is installed) this re-runs the probe-driven pick and
6140
+ * re-provisions. Idempotent: `startProvisioningForCurrentEngine` skips a
6141
+ * no-op when the selection is unchanged.
6142
+ */
6143
+ async repickEngineOnProbeReady() {
6144
+ const before = `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`;
6145
+ await this.reprobeEngine();
6146
+ const stored = await this.loadEngine();
6147
+ if (stored) {
6148
+ this.currentEngine = stored;
6149
+ this.needsAutoPick = false;
6150
+ this.cancelDeferredAutoPick();
6151
+ }
6152
+ const after = `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`;
6153
+ if (before !== after) this.log.info("Engine re-picked after platform-probe completed", { meta: {
6154
+ before,
6155
+ after
6156
+ } });
6157
+ this.startProvisioningForCurrentEngine();
6158
+ }
5945
6159
  async getReferenceAudioFiles() {
5946
6160
  const dir = resolveReferenceAudioDir();
5947
6161
  if (!dir) return [];
@@ -6077,11 +6291,11 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6077
6291
  }
6078
6292
  }
6079
6293
  };
6080
- function buildSchemaSlots(format, modelsDir) {
6294
+ function buildSchemaSlots(format, modelsDir, customByStep) {
6081
6295
  const slotMap = /* @__PURE__ */ new Map();
6082
6296
  for (const pipelineStep of ALL_PIPELINE_STEPS) {
6083
6297
  const step = pipelineStep.definition;
6084
- const availableModels = step.models.filter((m) => m.formats[format]);
6298
+ const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.formats[format]);
6085
6299
  if (availableModels.length === 0) continue;
6086
6300
  const slot = step.slot;
6087
6301
  if (!slotMap.has(slot)) slotMap.set(slot, []);
@@ -6186,8 +6400,7 @@ function buildDefaultStepTree(format) {
6186
6400
  makeStep("animal-classifier", [], { enabled: false }),
6187
6401
  makeStep("bird-classifier", [], { enabled: false }),
6188
6402
  makeStep("vehicle-classifier", [], { enabled: false }),
6189
- makeStep("segmentation-refiner", [], { enabled: false }),
6190
- makeStep("instance-segmentation", [], { enabled: false })
6403
+ makeStep("segmentation", [], { enabled: false })
6191
6404
  ].filter((s) => s !== null));
6192
6405
  const audioEngine = getDefaultModelForFormat("audio-classifier", format) === "apple-soundanalysis" ? {
6193
6406
  runtime: "python",
@@ -6410,6 +6623,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6410
6623
  nodeEngineBackend = DEFAULT_CONFIG.engineBackend;
6411
6624
  nodeProbedBestEngine = "";
6412
6625
  engineMetricsTimer = null;
6626
+ probePhaseUnsub = null;
6413
6627
  /** Snapshot-equality cache for engine-metrics emit. Most ticks
6414
6628
  * the engine inventory is unchanged (no model load/unload), so
6415
6629
  * we skip the bus emit and let the heartbeat re-emit at
@@ -6477,8 +6691,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6477
6691
  label: "Execution provider",
6478
6692
  options: [...STATIC_BACKEND_OPTIONS],
6479
6693
  default: DEFAULT_CONFIG.engineBackend,
6480
- immediate: true,
6481
- requiresRestart: true
6694
+ immediate: true
6482
6695
  }),
6483
6696
  this.field({
6484
6697
  type: "select",
@@ -6486,8 +6699,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6486
6699
  label: "Hardware device",
6487
6700
  options: [...STATIC_DEFAULT_DEVICE_OPTIONS],
6488
6701
  default: DEFAULT_CONFIG.engineDevice,
6489
- immediate: true,
6490
- requiresRestart: true
6702
+ immediate: true
6491
6703
  })
6492
6704
  ]
6493
6705
  }, {
@@ -6526,8 +6738,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6526
6738
  "onnx",
6527
6739
  "cpu"
6528
6740
  ]
6529
- },
6530
- requiresRestart: true
6741
+ }
6531
6742
  }),
6532
6743
  this.field({
6533
6744
  type: "slider",
@@ -6544,8 +6755,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6544
6755
  showWhen: {
6545
6756
  field: "batchMode",
6546
6757
  notEquals: "none"
6547
- },
6548
- requiresRestart: true
6758
+ }
6549
6759
  }),
6550
6760
  this.field({
6551
6761
  type: "slider",
@@ -6562,8 +6772,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6562
6772
  showWhen: {
6563
6773
  field: "batchMode",
6564
6774
  notEquals: "none"
6565
- },
6566
- requiresRestart: true
6775
+ }
6567
6776
  }),
6568
6777
  this.field({
6569
6778
  type: "slider",
@@ -6576,8 +6785,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6576
6785
  default: void 0,
6577
6786
  showValue: true,
6578
6787
  nullable: true,
6579
- nullLabel: "Auto",
6580
- requiresRestart: true
6788
+ nullLabel: "Auto"
6581
6789
  }),
6582
6790
  this.field({
6583
6791
  type: "slider",
@@ -6590,8 +6798,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6590
6798
  default: void 0,
6591
6799
  showValue: true,
6592
6800
  nullable: true,
6593
- nullLabel: "Auto",
6594
- requiresRestart: true
6801
+ nullLabel: "Auto"
6595
6802
  })
6596
6803
  ]
6597
6804
  }] });
@@ -6823,11 +7030,35 @@ var DetectionPipelineAddon = class extends BaseAddon {
6823
7030
  numWorkers: num(t["numWorkers"], 1)
6824
7031
  };
6825
7032
  }
6826
- async onInitialize() {
6827
- const modelsDir = await this.ctx.api.storage.resolve.query({
7033
+ /**
7034
+ * Resolve the directory models are downloaded into, resilient across nodes.
7035
+ * The `models` storage location is GLOBAL (hub-seeded) and can resolve to a
7036
+ * path that exists only on the hub — e.g. Docker's `/data/models`, which an
7037
+ * agent on a different filesystem (a Mac) cannot create (`ENOENT mkdir
7038
+ * /data/models`). Verify the resolved dir is creatable; otherwise fall back to
7039
+ * this node's LOCAL addon data-dir so models always land on writable disk.
7040
+ */
7041
+ async resolveModelsDir() {
7042
+ const fallback = path$1.join(this.ctx.dataDir, "models");
7043
+ const candidate = await this.ctx.api.storage.resolve.query({
6828
7044
  location: "models",
6829
7045
  relativePath: ""
6830
- }).catch(() => "camstack-data/models");
7046
+ }).catch(() => null) ?? fallback;
7047
+ try {
7048
+ await fs.promises.mkdir(candidate, { recursive: true });
7049
+ return candidate;
7050
+ } catch (err) {
7051
+ this.ctx.logger.warn("models dir not creatable on this node — using local data-dir", { meta: {
7052
+ resolved: candidate,
7053
+ fallback,
7054
+ error: err instanceof Error ? err.message : String(err)
7055
+ } });
7056
+ await fs.promises.mkdir(fallback, { recursive: true });
7057
+ return fallback;
7058
+ }
7059
+ }
7060
+ async onInitialize() {
7061
+ const modelsDir = await this.resolveModelsDir();
6831
7062
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available");
6832
7063
  await this.refreshNodeEngineFromStore();
6833
7064
  this.pythonAddonDir = resolveAddonPythonDir();
@@ -6857,6 +7088,12 @@ var DetectionPipelineAddon = class extends BaseAddon {
6857
7088
  await this.provider.ensureBootEngineProvisioned().catch((err) => {
6858
7089
  this.ctx.logger.warn("ensureBootEngineProvisioned failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
6859
7090
  });
7091
+ this.probePhaseUnsub = this.ctx.eventBus?.subscribe({ category: EventCategory.PlatformProbePhase }, (event) => {
7092
+ if (event.data?.phase !== "done") return;
7093
+ this.provider.repickEngineOnProbeReady().catch((err) => {
7094
+ this.ctx.logger.warn("repick on platform-probe done failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
7095
+ });
7096
+ }) ?? null;
6860
7097
  await this.provider.warmPool();
6861
7098
  this.engineMetricsTimer = setInterval(() => this.emitEngineMetricsSnapshot(), ENGINE_METRICS_SNAPSHOT_INTERVAL_MS);
6862
7099
  this.lastAppliedPoolConfig = this.snapshotPoolConfig();
@@ -6930,6 +7167,10 @@ var DetectionPipelineAddon = class extends BaseAddon {
6930
7167
  }
6931
7168
  }
6932
7169
  async onShutdown() {
7170
+ if (this.probePhaseUnsub) {
7171
+ this.probePhaseUnsub();
7172
+ this.probePhaseUnsub = null;
7173
+ }
6933
7174
  if (this.engineMetricsTimer) {
6934
7175
  clearInterval(this.engineMetricsTimer);
6935
7176
  this.engineMetricsTimer = null;
@@ -6964,16 +7205,17 @@ var DetectionPipelineAddon = class extends BaseAddon {
6964
7205
  return false;
6965
7206
  }
6966
7207
  /**
6967
- * BaseAddon calls `onConfigChanged` after every settings write.
6968
- * Group-runner addons can't honour the schema's `requiresRestart:
6969
- * true` flag (the restart cap returns "process not found" for
6970
- * processes spawned in a kernel group worker). To make tuning
6971
- * changes actually take effect, watch the pool-bound subset and
6972
- * respawn the provider in place when it flips.
6973
- *
6974
- * Engine cascade (`engineRuntime/Backend/Device`) and audio
6975
- * settings don't require this those still rely on the addon
6976
- * lifecycle (engineFactory rebuild on next runPipeline).
7208
+ * BaseAddon calls `onConfigChanged` after every settings write. This is the
7209
+ * SOLE apply path for engine + tuning changes — none of those fields declare
7210
+ * `requiresRestart` anymore (an addon restart re-probes hardware + reloads
7211
+ * every model on every set, which is exactly what we want to avoid). Instead:
7212
+ * - Pool-bound tuning (`concurrency`/`batchMode`/`windowMs`/…) in-place
7213
+ * pool respawn when the snapshot flips (`poolConfigChanged` below).
7214
+ * - Engine cascade (`engineBackend`/`engineDevice`) → the provider's
7215
+ * `onEngineSelectionChanged` disposes the device-bound factory so the next
7216
+ * runPipeline rebuilds the pool on the new selection, in place.
7217
+ * Both apply optimistically the inference gate stays closed for the short
7218
+ * re-spin, frames are dropped (never crashed), no addon bounce.
6977
7219
  */
6978
7220
  async onConfigChanged() {
6979
7221
  await this.refreshNodeEngineFromStore();
@@ -6994,10 +7236,7 @@ var DetectionPipelineAddon = class extends BaseAddon {
6994
7236
  } catch (err) {
6995
7237
  this.ctx.logger.warn("provider shutdown failed during tuning respawn", { meta: { error: err instanceof Error ? err.message : String(err) } });
6996
7238
  }
6997
- const modelsDir = await this.ctx.api.storage.resolve.query({
6998
- location: "models",
6999
- relativePath: ""
7000
- }).catch(() => "camstack-data/models");
7239
+ const modelsDir = await this.resolveModelsDir();
7001
7240
  if (!this.ctx.settings) throw new Error("DetectionPipelineAddon: ctx.settings not available during respawn");
7002
7241
  const effectiveTuning = this.resolveBackendTuning();
7003
7242
  this.provider = new DetectionPipelineProvider(this.ctx.settings, modelsDir, this.ctx.logger, this.ctx.eventBus ?? null, () => ({ sections: [] }), {