@camstack/addon-pipeline 1.2.104 → 1.2.106

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 (33) hide show
  1. package/dist/{addon-utils-DRbRzrDy.js → addon-utils-BEzVJ0xg.js} +1 -1
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/detection-pipeline/index.js +367 -40
  5. package/dist/detection-pipeline/index.mjs +365 -38
  6. package/dist/{dist-gLAVhbvO.js → dist-BSeR_hVv.js} +76 -6
  7. package/dist/{dist-D9sltFoR.mjs → dist-lITI1Bn5.mjs} +76 -6
  8. package/dist/{event-loop-stall-monitor-BS_lPX6H.mjs → event-loop-stall-monitor-0VvjTRDP.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-BUhYY39J.js → event-loop-stall-monitor-BNsp9k8r.js} +1 -1
  10. package/dist/{lazy-sharp-OiAUva0g.js → lazy-sharp-CBohM8D8.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-DY4RHcTj.js → process-memory-C33u3tQz.js} +1 -1
  16. package/dist/{process-memory-Dw7-9jCI.mjs → process-memory-CEKVKfwg.mjs} +1 -1
  17. package/dist/recorder/index.js +2 -2
  18. package/dist/recorder/index.mjs +1 -1
  19. package/dist/session-decode/decode-worker-child.js +2 -2
  20. package/dist/session-decode/decode-worker-child.mjs +1 -1
  21. package/dist/stream-broker/_stub.js +1 -1
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ZaqZ17-x.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-w2XLD3uI.mjs} +3 -3
  23. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DvZWLWk1.mjs +26 -0
  24. package/dist/stream-broker/{hostInit-aZY81RnZ.mjs → hostInit-B-h24cDy.mjs} +3 -3
  25. package/dist/stream-broker/index.js +1 -1
  26. package/dist/stream-broker/index.mjs +1 -1
  27. package/dist/stream-broker/remoteEntry.js +1 -1
  28. package/dist/{worker-protocol-Bz7-sMZC.mjs → worker-protocol-BDiL8TBm.mjs} +1 -1
  29. package/dist/{worker-protocol-D_0q_4Le.js → worker-protocol-CiKfal2P.js} +1 -1
  30. package/package.json +1 -1
  31. package/python/inference_pool.py +45 -1
  32. package/python/test_inference_pool_reconfigure.py +122 -0
  33. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BnhMnnKV.mjs +0 -26
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-gLAVhbvO.js");
1
+ const require_dist = require("./dist-BSeR_hVv.js");
2
2
  require("node:crypto");
3
3
  let node_path = require("node:path");
4
4
  node_path = require_dist.__toESM(node_path, 1);
@@ -2,9 +2,9 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-gLAVhbvO.js");
6
- const require_process_memory = require("../process-memory-DY4RHcTj.js");
7
- const require_addon_utils = require("../addon-utils-DRbRzrDy.js");
5
+ const require_dist = require("../dist-BSeR_hVv.js");
6
+ const require_process_memory = require("../process-memory-C33u3tQz.js");
7
+ const require_addon_utils = require("../addon-utils-BEzVJ0xg.js");
8
8
  let node_path = require("node:path");
9
9
  node_path = require_dist.__toESM(node_path);
10
10
  let node_fs = require("node:fs");
@@ -1,6 +1,6 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
- import { Ct as hydrateSchema, K as mapAudioLabelToMacro, O as audioAnalysisCapability, at as resolvePoolMemoryPolicy, b as PoolMemoryWatchdog, ft as errMsg, g as HF_BASE_URL, k as audioAnalyzerCapability, n as AUDIO_BACKEND_CHOICES, s as DEFAULT_AUDIO_ANALYZER_CONFIG, vt as BaseAddon } from "../dist-D9sltFoR.mjs";
3
- import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-Dw7-9jCI.mjs";
2
+ import { Ct as hydrateSchema, K as mapAudioLabelToMacro, O as audioAnalysisCapability, at as resolvePoolMemoryPolicy, b as PoolMemoryWatchdog, ft as errMsg, g as HF_BASE_URL, k as audioAnalyzerCapability, n as AUDIO_BACKEND_CHOICES, s as DEFAULT_AUDIO_ANALYZER_CONFIG, vt as BaseAddon } from "../dist-lITI1Bn5.mjs";
3
+ import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-CEKVKfwg.mjs";
4
4
  import { i as downloadFile } from "../addon-utils-A2S9D7pu.mjs";
5
5
  import * as path$1 from "node:path";
6
6
  import * as fs from "node:fs";
@@ -2,11 +2,11 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-gLAVhbvO.js");
6
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-BUhYY39J.js");
7
- const require_lazy_sharp = require("../lazy-sharp-OiAUva0g.js");
8
- const require_process_memory = require("../process-memory-DY4RHcTj.js");
9
- const require_addon_utils = require("../addon-utils-DRbRzrDy.js");
5
+ const require_dist = require("../dist-BSeR_hVv.js");
6
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-BNsp9k8r.js");
7
+ const require_lazy_sharp = require("../lazy-sharp-CBohM8D8.js");
8
+ const require_process_memory = require("../process-memory-C33u3tQz.js");
9
+ const require_addon_utils = require("../addon-utils-BEzVJ0xg.js");
10
10
  let sharp = require("sharp");
11
11
  sharp = require_dist.__toESM(sharp);
12
12
  let node_child_process = require("node:child_process");
@@ -1076,6 +1076,25 @@ var SharedInferencePool = class {
1076
1076
  })));
1077
1077
  for (const resp of responses) if (resp.status !== "ok") throw new Error(`Failed to unload model at index ${index}: ${resp.error ?? "unknown"}`);
1078
1078
  }
1079
+ /**
1080
+ * Retune a LOADED slot's decode-scoped settings without touching its
1081
+ * inference session — the narrow counterpart of `replaceModel`.
1082
+ *
1083
+ * `replace` is `unload` + `load`: it re-instantiates the session (~1.4s on
1084
+ * coreml, and an OpenVINO recompile) and stalls every live camera routed to
1085
+ * this pool. An operator moving the NMS slider must not cost that, and must
1086
+ * not touch any other slot: the Python side applies the patch to `index`
1087
+ * only, and refuses any key outside its reconfigurable allow-list rather
1088
+ * than accepting a `path` change that would silently not reload.
1089
+ */
1090
+ async reconfigureModel(index, settings) {
1091
+ const responses = await Promise.all(this.workers.map((w) => w.sendCommand({
1092
+ cmd: "reconfigure",
1093
+ index,
1094
+ config: { nmsIouThreshold: settings.nmsIouThreshold }
1095
+ })));
1096
+ for (const resp of responses) if (resp.status !== "ok") throw new Error(`Failed to reconfigure model at index ${index}: ${resp.error ?? "unknown"}`);
1097
+ }
1079
1098
  async replaceModel(index, config) {
1080
1099
  const responses = await Promise.all(this.workers.map((w) => w.sendCommand({
1081
1100
  cmd: "replace",
@@ -1303,10 +1322,188 @@ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
1303
1322
  };
1304
1323
  }
1305
1324
  //#endregion
1325
+ //#region src/detection-pipeline/registry/effective-postprocessor.ts
1326
+ function resolveEffectivePostprocessor(definition, modelId) {
1327
+ return resolveEffectivePostprocessorForEntry(definition, modelId ? definition.models.find((m) => m.id === modelId) : void 0);
1328
+ }
1329
+ /**
1330
+ * Entry-based variant for callers that already hold the `ModelCatalogEntry` —
1331
+ * required for CUSTOM (operator-registered) models, which are NOT in
1332
+ * `definition.models` and would otherwise silently fall back to the step
1333
+ * default decode (a Frigate `yolonas` model decoded as `yolo` produces no
1334
+ * error anywhere, just zero detections).
1335
+ */
1336
+ function resolveEffectivePostprocessorForEntry(definition, entry) {
1337
+ return entry?.postprocessor ?? definition.postprocessor;
1338
+ }
1339
+ //#endregion
1340
+ //#region src/detection-pipeline/registry/model-knob-applicability.ts
1341
+ /** Decodes that run NMS in the pipeline (and hence consult `nmsIouThreshold`).
1342
+ * Mirrors the Python postprocessors that read the knob — yolo.py,
1343
+ * yolo_seg.py, rfdetr.py, scrfd.py. */
1344
+ var NMS_CONSUMING_POSTPROCESSORS = new Set([
1345
+ "yolo",
1346
+ "yolo-seg",
1347
+ "rfdetr",
1348
+ "scrfd"
1349
+ ]);
1350
+ /**
1351
+ * Does this decode consult `nmsIouThreshold`?
1352
+ *
1353
+ * ONE predicate for the whole system: the UI disables the slider through
1354
+ * {@link annotateConfigSchemaForModels}, and the runtime refuses to forward a
1355
+ * value through `resolvePoolDecodeSettings` — both off this set. Two copies of
1356
+ * the matrix would let the UI promise an effect the pool does not deliver,
1357
+ * which is the failure D214 exists to remove.
1358
+ */
1359
+ function postprocessorConsumesNms(postprocessor) {
1360
+ return NMS_CONSUMING_POSTPROCESSORS.has(postprocessor);
1361
+ }
1362
+ var MIN_CONFIDENCE_MACRO_PREFIX = "minConfidence";
1363
+ /** `minConfidencePerson` → `person`; bare `minConfidence` → null (generic). */
1364
+ function macroOfMinConfidenceKey(key) {
1365
+ if (!key.startsWith(MIN_CONFIDENCE_MACRO_PREFIX)) return null;
1366
+ const rest = key.slice(13);
1367
+ if (rest.length === 0) return null;
1368
+ return rest.charAt(0).toLowerCase() + rest.slice(1);
1369
+ }
1370
+ /**
1371
+ * The macro classes a model's OWN labelmap can reach through the step's
1372
+ * classMap. `undefined` ⇒ the model declares no labels (catalog default
1373
+ * labelmap) — every macro of the step is reachable, nothing to annotate.
1374
+ */
1375
+ function reachableMacros(def, entry) {
1376
+ if (entry.labels.length === 0) return void 0;
1377
+ const map = def.classMap;
1378
+ if (!map) return new Set(entry.labels.map((l) => l.id));
1379
+ if (map.preserveOriginal) return void 0;
1380
+ const macros = /* @__PURE__ */ new Set();
1381
+ for (const label of entry.labels) {
1382
+ const mapped = map.mapping[label.id];
1383
+ if (mapped !== void 0) macros.add(mapped);
1384
+ }
1385
+ return macros;
1386
+ }
1387
+ /**
1388
+ * The labels a custom model declares that the step's dropping classMap
1389
+ * (`preserveOriginal: false`) removes UPSTREAM of every operator setting.
1390
+ * `null` when the model declares no labels, the step has no classMap, the map
1391
+ * preserves originals, or nothing is dropped. The engine factory logs this at
1392
+ * pool-config time — a dropped class must never be silent (CLAUDE.md logging
1393
+ * rule; the discard trail alone answers "what", not "why zero forever").
1394
+ */
1395
+ function collectClassMapDropWarning(def, entry) {
1396
+ if (entry.labels.length === 0) return null;
1397
+ const map = def.classMap;
1398
+ if (!map || map.preserveOriginal) return null;
1399
+ const unmapped = entry.labels.filter((l) => map.mapping[l.id] === void 0).map((l) => l.id);
1400
+ if (unmapped.length === 0) return null;
1401
+ return {
1402
+ unmappedLabels: unmapped,
1403
+ mappedCount: entry.labels.length - unmapped.length
1404
+ };
1405
+ }
1406
+ function toApplicability(acc) {
1407
+ const hasInapplicable = Object.keys(acc.inapplicable).length > 0;
1408
+ const hasOptions = Object.keys(acc.optionsByModel).length > 0;
1409
+ if (!hasInapplicable && !hasOptions) return void 0;
1410
+ return {
1411
+ ...hasInapplicable ? { inapplicable: acc.inapplicable } : {},
1412
+ ...hasOptions ? { optionsByModel: acc.optionsByModel } : {}
1413
+ };
1414
+ }
1415
+ function isAnnotatable(field) {
1416
+ return field.type === "slider" || field.type === "number" || field.type === "multiselect" || field.type === "select" || field.type === "boolean";
1417
+ }
1418
+ function annotateField(field, inputs) {
1419
+ if (!isAnnotatable(field)) return field;
1420
+ const { def, models, customModelIds } = inputs;
1421
+ const acc = {
1422
+ inapplicable: {},
1423
+ optionsByModel: {}
1424
+ };
1425
+ const macroKey = macroOfMinConfidenceKey(field.key);
1426
+ for (const entry of models) {
1427
+ const postprocessor = resolveEffectivePostprocessorForEntry(def, entry);
1428
+ if (field.key === "nmsIouThreshold" && !postprocessorConsumesNms(postprocessor)) {
1429
+ acc.inapplicable[entry.id] = `NMS is fused inside this model's "${postprocessor}" export — the pipeline IoU threshold is not consulted.`;
1430
+ continue;
1431
+ }
1432
+ if (field.key === "rectify" && postprocessor !== "ctc") {
1433
+ acc.inapplicable[entry.id] = `The "${postprocessor}" reader consumes the raw plate box — its crop policy never deskews, so this switch has no effect.`;
1434
+ continue;
1435
+ }
1436
+ if (macroKey !== null) {
1437
+ const macros = reachableMacros(def, entry);
1438
+ if (macros !== void 0 && !macros.has(macroKey)) acc.inapplicable[entry.id] = `This model's labelmap cannot produce "${macroKey}" — the slider filters a class that never arrives.`;
1439
+ continue;
1440
+ }
1441
+ if (field.key === "enabledMacroClasses" && field.type === "multiselect") {
1442
+ const macros = reachableMacros(def, entry);
1443
+ if (macros === void 0) continue;
1444
+ const reachable = field.options.filter((o) => macros.has(o.value));
1445
+ if (reachable.length === 0) acc.inapplicable[entry.id] = "None of this model's labels map to a macro class the pipeline tracks — every detection it emits is dropped upstream of this filter.";
1446
+ else if (reachable.length < field.options.length) acc.optionsByModel[entry.id] = reachable.map((o) => ({
1447
+ value: o.value,
1448
+ label: o.label
1449
+ }));
1450
+ continue;
1451
+ }
1452
+ if (field.key === "enabledClasses" && field.type === "multiselect" && customModelIds.has(entry.id) && entry.labels.length > 0) {
1453
+ acc.optionsByModel[entry.id] = entry.labels.map((l) => ({
1454
+ value: l.id,
1455
+ label: l.name
1456
+ }));
1457
+ continue;
1458
+ }
1459
+ }
1460
+ const applicability = toApplicability(acc);
1461
+ if (applicability === void 0) return field;
1462
+ return {
1463
+ ...field,
1464
+ modelApplicability: applicability
1465
+ };
1466
+ }
1467
+ /**
1468
+ * Decorate a step's config schema with per-model applicability, computed from
1469
+ * the same model union the picker offers. Fields with nothing to say are
1470
+ * returned untouched (identity), so a static-only step with fully-consuming
1471
+ * models produces a byte-identical schema.
1472
+ */
1473
+ function annotateConfigSchemaForModels(inputs) {
1474
+ return inputs.configSchema.map((field) => annotateField(field, inputs));
1475
+ }
1476
+ /**
1477
+ * A persisted settings blob is external data — it survives schema changes,
1478
+ * hand edits and migrations. Accept only a finite IoU in the open (0,1)
1479
+ * interval; anything else falls back rather than reaching the decode, where a
1480
+ * 0 would suppress every overlapping box and a 1 would suppress none.
1481
+ */
1482
+ function readIouThreshold(value) {
1483
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
1484
+ if (value <= 0 || value >= 1) return void 0;
1485
+ return value;
1486
+ }
1487
+ /** The effective decode-scoped values for a `(step, model)` pair. */
1488
+ function resolvePoolDecodeSettings(inputs) {
1489
+ const fallback = readIouThreshold(inputs.fallbackNmsIouThreshold) ?? .45;
1490
+ if (!postprocessorConsumesNms(resolveEffectivePostprocessorForEntry(inputs.def, inputs.modelEntry))) return { nmsIouThreshold: fallback };
1491
+ return { nmsIouThreshold: readIouThreshold(inputs.settings?.["nmsIouThreshold"]) ?? fallback };
1492
+ }
1493
+ /** What a built pool config actually carries — the state the slot is IN after
1494
+ * a `load`, so the manager's diff is anchored to the command it sent, never
1495
+ * to a second derivation of the same inputs. */
1496
+ function poolDecodeSettingsOf(config) {
1497
+ return { nmsIouThreshold: config.nmsIouThreshold ?? .45 };
1498
+ }
1499
+ function poolDecodeSettingsEqual(a, b) {
1500
+ return a.nmsIouThreshold === b.nmsIouThreshold;
1501
+ }
1502
+ //#endregion
1306
1503
  //#region src/detection-pipeline/engine/pipeline-model-manager.ts
1307
1504
  var PipelineModelManager = class {
1308
1505
  pool;
1309
- buildPoolConfig;
1506
+ source;
1310
1507
  /** stepId → modelId → loaded entry. */
1311
1508
  loaded = /* @__PURE__ */ new Map();
1312
1509
  /** stepId → currently-active modelId (the runtime choice). */
@@ -1315,9 +1512,9 @@ var PipelineModelManager = class {
1315
1512
  lruClock = 0;
1316
1513
  log;
1317
1514
  maxModelsPerStep;
1318
- constructor(pool, buildPoolConfig, logger, options) {
1515
+ constructor(pool, source, logger, options) {
1319
1516
  this.pool = pool;
1320
- this.buildPoolConfig = buildPoolConfig;
1517
+ this.source = source;
1321
1518
  this.log = logger;
1322
1519
  this.maxModelsPerStep = options?.maxModelsPerStep ?? 4;
1323
1520
  }
@@ -1341,7 +1538,7 @@ var PipelineModelManager = class {
1341
1538
  } });
1342
1539
  }
1343
1540
  for (const added of diff.added) {
1344
- await this.ensureLoaded(added.addonId, added.modelId);
1541
+ await this.ensureLoaded(added.addonId, added.modelId, added.settings);
1345
1542
  this.activeByStep.set(added.addonId, added.modelId);
1346
1543
  this.log.info("Step loaded", { meta: {
1347
1544
  step: added.addonId,
@@ -1350,7 +1547,7 @@ var PipelineModelManager = class {
1350
1547
  } });
1351
1548
  }
1352
1549
  for (const { step, oldModelId } of diff.modelChanged) {
1353
- await this.ensureLoaded(step.addonId, step.modelId);
1550
+ await this.ensureLoaded(step.addonId, step.modelId, step.settings);
1354
1551
  this.activeByStep.set(step.addonId, step.modelId);
1355
1552
  this.log.info("Active model switched", { meta: {
1356
1553
  step: step.addonId,
@@ -1359,6 +1556,7 @@ var PipelineModelManager = class {
1359
1556
  } });
1360
1557
  await this.enforceLruCap(step.addonId);
1361
1558
  }
1559
+ for (const { entry, step } of diff.unchanged) await this.reconcileDecode(entry, step.settings);
1362
1560
  }
1363
1561
  /**
1364
1562
  * Get an `IInferenceEngine` handle. Without `modelId`, returns the
@@ -1380,6 +1578,22 @@ var PipelineModelManager = class {
1380
1578
  isLoadedWithModel(stepId, modelId) {
1381
1579
  return this.loaded.get(stepId)?.has(modelId) ?? false;
1382
1580
  }
1581
+ /**
1582
+ * Does the pool NOT yet reflect this step? True when the (step, model) pair
1583
+ * is absent, and also when it is resident with different decode-scoped
1584
+ * settings — the case `isLoadedWithModel` cannot see.
1585
+ *
1586
+ * This is the per-dispatch skip gate: callers that short-circuit on
1587
+ * "already loaded" were the reason a saved threshold never reached a
1588
+ * running pool. Cheap by contract — `resolveDecode` never touches the fs.
1589
+ */
1590
+ needsPoolUpdate(step) {
1591
+ const entry = this.loaded.get(step.addonId)?.get(step.modelId);
1592
+ if (!entry) return true;
1593
+ const desired = this.source.resolveDecode(step.addonId, step.modelId, step.settings);
1594
+ if (desired === null) return true;
1595
+ return !poolDecodeSettingsEqual(entry.decode, desired);
1596
+ }
1383
1597
  /** ModelId currently designated as active for the step. */
1384
1598
  getLoadedModelId(stepId) {
1385
1599
  return this.activeByStep.get(stepId);
@@ -1431,8 +1645,7 @@ var PipelineModelManager = class {
1431
1645
  */
1432
1646
  async loadAdditional(steps) {
1433
1647
  for (const step of steps) {
1434
- if (this.isLoadedWithModel(step.addonId, step.modelId)) continue;
1435
- await this.ensureLoaded(step.addonId, step.modelId);
1648
+ await this.ensureLoaded(step.addonId, step.modelId, step.settings);
1436
1649
  if (!this.activeByStep.has(step.addonId)) this.activeByStep.set(step.addonId, step.modelId);
1437
1650
  }
1438
1651
  }
@@ -1441,7 +1654,7 @@ var PipelineModelManager = class {
1441
1654
  * Honors `maxModelsPerStep` — evicts a non-active LRU variant first
1442
1655
  * when the cap would otherwise be exceeded.
1443
1656
  */
1444
- async ensureLoaded(stepId, modelId) {
1657
+ async ensureLoaded(stepId, modelId, settings) {
1445
1658
  let perStep = this.loaded.get(stepId);
1446
1659
  if (!perStep) {
1447
1660
  perStep = /* @__PURE__ */ new Map();
@@ -1450,6 +1663,7 @@ var PipelineModelManager = class {
1450
1663
  const existing = perStep.get(modelId);
1451
1664
  if (existing) {
1452
1665
  this.touch(existing);
1666
+ await this.reconcileDecode(existing, settings);
1453
1667
  return existing;
1454
1668
  }
1455
1669
  while (perStep.size >= this.maxModelsPerStep) {
@@ -1464,11 +1678,13 @@ var PipelineModelManager = class {
1464
1678
  } });
1465
1679
  }
1466
1680
  const index = this.pool.allocateIndex();
1467
- const config = this.buildPoolConfig(stepId, modelId);
1681
+ const config = this.source.buildConfig(stepId, modelId, settings);
1682
+ const decode = poolDecodeSettingsOf(config);
1468
1683
  this.log.info("Loading step variant", { meta: {
1469
1684
  step: stepId,
1470
1685
  modelId,
1471
- poolIndex: index
1686
+ poolIndex: index,
1687
+ ...decode
1472
1688
  } });
1473
1689
  const { loadMs } = await this.pool.loadModel(index, config);
1474
1690
  this.log.info("Step variant loaded", { meta: {
@@ -1481,11 +1697,71 @@ var PipelineModelManager = class {
1481
1697
  stepId,
1482
1698
  modelId,
1483
1699
  poolIndex: index,
1484
- lruTick: ++this.lruClock
1700
+ lruTick: ++this.lruClock,
1701
+ decode
1485
1702
  };
1486
1703
  perStep.set(modelId, entry);
1487
1704
  return entry;
1488
1705
  }
1706
+ /**
1707
+ * Push a retuned decode setting into ONE already-loaded slot.
1708
+ *
1709
+ * Scope is the point: `reconfigure` names a single pool index, so a slider
1710
+ * moved on `object-detection/yolo26n` never disturbs the face detector, the
1711
+ * classifiers, or the same step's warm benchmark variants — and it never
1712
+ * costs a session reload (see `SharedInferencePool.reconfigureModel`).
1713
+ *
1714
+ * A no-op when nothing moved: this runs on the per-dispatch path, so the
1715
+ * common case must send zero commands.
1716
+ */
1717
+ async reconcileDecode(entry, settings) {
1718
+ const desired = this.source.resolveDecode(entry.stepId, entry.modelId, settings);
1719
+ if (desired === null) return;
1720
+ if (poolDecodeSettingsEqual(entry.decode, desired)) return;
1721
+ const previous = entry.decode;
1722
+ await this.pool.reconfigureModel(entry.poolIndex, desired);
1723
+ entry.decode = desired;
1724
+ this.log.info("Decode settings retuned in place — no model reload", { meta: {
1725
+ step: entry.stepId,
1726
+ modelId: entry.modelId,
1727
+ poolIndex: entry.poolIndex,
1728
+ previousNmsIouThreshold: previous.nmsIouThreshold,
1729
+ ...desired
1730
+ } });
1731
+ this.reportContention(entry, previous, desired);
1732
+ }
1733
+ /** Decode values each (step, model) pair has already run, so a value
1734
+ * RETURNING can be told apart from a value being changed. */
1735
+ decodeHistory = /* @__PURE__ */ new Map();
1736
+ contentionReported = /* @__PURE__ */ new Set();
1737
+ /**
1738
+ * A pool slot is NODE-level; the settings driving it arrive per dispatch, so
1739
+ * two cameras with different per-camera overrides for the same (step, model)
1740
+ * pull the same slot back and forth. That is not expressible — one of them is
1741
+ * always decoding with the other's threshold — and it is invisible from the
1742
+ * outside, so it is reported the first time a pair flip-flops BACK to a value
1743
+ * it already ran. One steady retune (the operator moving a slider) never
1744
+ * trips it; alternating writers do, immediately.
1745
+ */
1746
+ reportContention(entry, previous, desired) {
1747
+ const key = `${entry.stepId}::${entry.modelId}`;
1748
+ let seen = this.decodeHistory.get(key);
1749
+ if (!seen) {
1750
+ seen = new Set([previous.nmsIouThreshold]);
1751
+ this.decodeHistory.set(key, seen);
1752
+ }
1753
+ const returning = seen.has(desired.nmsIouThreshold);
1754
+ seen.add(desired.nmsIouThreshold);
1755
+ if (!returning || this.contentionReported.has(key)) return;
1756
+ this.contentionReported.add(key);
1757
+ this.log.warn("Two dispatches disagree on this step’s decode settings — the pool slot is node-level, so one of them always decodes with the other’s value", { meta: {
1758
+ step: entry.stepId,
1759
+ modelId: entry.modelId,
1760
+ poolIndex: entry.poolIndex,
1761
+ valuesSeen: [...seen].sort((a, b) => a - b),
1762
+ hint: "a per-camera override of a decode-scoped knob cannot be honoured on a shared pool — keep this knob at the node level"
1763
+ } });
1764
+ }
1489
1765
  async unloadEntry(entry) {
1490
1766
  await this.pool.unloadModel(entry.poolIndex);
1491
1767
  const perStep = this.loaded.get(entry.stepId);
@@ -1551,7 +1827,10 @@ var PipelineModelManager = class {
1551
1827
  step: desired,
1552
1828
  oldModelId: activeModelId
1553
1829
  });
1554
- else unchanged.push(activeEntry);
1830
+ else unchanged.push({
1831
+ entry: activeEntry,
1832
+ step: desired
1833
+ });
1555
1834
  }
1556
1835
  for (const [stepId, step] of desiredMap) if (!this.activeByStep.has(stepId)) added.push(step);
1557
1836
  return {
@@ -1563,21 +1842,6 @@ var PipelineModelManager = class {
1563
1842
  }
1564
1843
  };
1565
1844
  //#endregion
1566
- //#region src/detection-pipeline/registry/effective-postprocessor.ts
1567
- function resolveEffectivePostprocessor(definition, modelId) {
1568
- return resolveEffectivePostprocessorForEntry(definition, modelId ? definition.models.find((m) => m.id === modelId) : void 0);
1569
- }
1570
- /**
1571
- * Entry-based variant for callers that already hold the `ModelCatalogEntry` —
1572
- * required for CUSTOM (operator-registered) models, which are NOT in
1573
- * `definition.models` and would otherwise silently fall back to the step
1574
- * default decode (a Frigate `yolonas` model decoded as `yolo` produces no
1575
- * error anywhere, just zero detections).
1576
- */
1577
- function resolveEffectivePostprocessorForEntry(definition, entry) {
1578
- return entry?.postprocessor ?? definition.postprocessor;
1579
- }
1580
- //#endregion
1581
1845
  //#region src/detection-pipeline/engine/resolve-device-engine.ts
1582
1846
  /**
1583
1847
  * Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
@@ -1844,6 +2108,14 @@ var EngineFactory = class {
1844
2108
  return this.poolManager?.isLoadedWithModel(stepId, modelId) ?? false;
1845
2109
  }
1846
2110
  /**
2111
+ * Does the pool still owe this step something — a load, or a retune of its
2112
+ * decode-scoped settings? The skip gate every dispatch path must use instead
2113
+ * of `isLoadedWithModel`, which cannot see a changed threshold (D214).
2114
+ */
2115
+ needsPoolUpdate(step) {
2116
+ return this.poolManager?.needsPoolUpdate(step) ?? true;
2117
+ }
2118
+ /**
1847
2119
  * List every loaded (stepId, modelId) variant — the pool surfaces each
1848
2120
  * warm slot independently (including bench overrides).
1849
2121
  */
@@ -1979,19 +2251,57 @@ var EngineFactory = class {
1979
2251
  numWorkers,
1980
2252
  ...this.opts.engine.device ? { device: this.opts.engine.device } : {}
1981
2253
  });
1982
- this.poolManager = new PipelineModelManager(this.pool, (stepId, modelId) => this.buildPoolModelConfig(stepId, modelId, poolRuntime), this.log.child("model-mgr"));
2254
+ this.poolManager = new PipelineModelManager(this.pool, {
2255
+ buildConfig: (stepId, modelId, settings) => this.buildPoolModelConfig(stepId, modelId, poolRuntime, settings),
2256
+ resolveDecode: (stepId, modelId, settings) => this.resolveDecode(stepId, modelId, settings)
2257
+ }, this.log.child("model-mgr"));
1983
2258
  await this.pool.initialize([]);
1984
2259
  await this.poolManager.applyConfig(steps);
1985
2260
  }
1986
- buildPoolModelConfig(stepId, modelId, poolRuntime) {
2261
+ /** (step, model) pairs whose classMap-drop warning already logged — the
2262
+ * pool reloads models on every applyConfig; the fact doesn't change. */
2263
+ classMapDropLogged = /* @__PURE__ */ new Set();
2264
+ /**
2265
+ * The fs-free half of {@link PoolModelSource} — the effective decode-scoped
2266
+ * settings for a `(step, model, settings)` triple. Runs on the per-dispatch
2267
+ * path, so it must resolve the catalog entry and nothing more; the label
2268
+ * file, the format build and the model path are load-time concerns.
2269
+ *
2270
+ * `null` when the pair resolves to no model at all — the caller routes that
2271
+ * to `buildConfig`, which throws the message naming the step and the model.
2272
+ */
2273
+ resolveDecode(stepId, modelId, settings) {
2274
+ const def = require_event_loop_stall_monitor.getStepDefinition(stepId);
2275
+ const modelEntry = def.models.find((m) => m.id === modelId) ?? this.opts.resolveCustomModel?.(stepId, modelId);
2276
+ if (!modelEntry) return null;
2277
+ return resolvePoolDecodeSettings({
2278
+ def,
2279
+ modelEntry,
2280
+ settings,
2281
+ fallbackNmsIouThreshold: this.opts.nmsIouThreshold
2282
+ });
2283
+ }
2284
+ buildPoolModelConfig(stepId, modelId, poolRuntime, stepSettings) {
1987
2285
  return buildPoolModelConfigForStep({
1988
2286
  stepId,
1989
2287
  modelId,
1990
2288
  poolRuntime,
1991
2289
  modelsDir: this.opts.modelsDir,
1992
2290
  nmsIouThreshold: this.opts.nmsIouThreshold,
2291
+ stepSettings,
1993
2292
  device: this.opts.engine.device,
1994
- resolveCustomModel: this.opts.resolveCustomModel
2293
+ resolveCustomModel: this.opts.resolveCustomModel,
2294
+ onClassMapDrop: ({ stepId: sid, modelId: mid, unmappedLabels }) => {
2295
+ const key = `${sid}::${mid}`;
2296
+ if (this.classMapDropLogged.has(key)) return;
2297
+ this.classMapDropLogged.add(key);
2298
+ this.log.warn("Model labels dropped by step classMap — unreachable by any setting", { meta: {
2299
+ step: sid,
2300
+ modelId: mid,
2301
+ unmappedLabels: [...unmappedLabels],
2302
+ hint: "these classes are removed before enabledMacroClasses / minConfidence* run; only labels in the step classMap are tracked"
2303
+ } });
2304
+ }
1995
2305
  });
1996
2306
  }
1997
2307
  };
@@ -2010,6 +2320,12 @@ function buildPoolModelConfigForStep(inputs) {
2010
2320
  if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${stepId}" catalog (static or custom registry)`);
2011
2321
  const format = RUNTIME_TO_FORMAT[poolRuntime];
2012
2322
  if (!format) throw new Error(`No format mapping for pool runtime "${poolRuntime}"`);
2323
+ const dropWarning = collectClassMapDropWarning(def, modelEntry);
2324
+ if (dropWarning !== null) inputs.onClassMapDrop?.({
2325
+ stepId,
2326
+ modelId,
2327
+ unmappedLabels: dropWarning.unmappedLabels
2328
+ });
2013
2329
  const formatEntry = modelEntry.formats[format];
2014
2330
  if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
2015
2331
  const urlParts = formatEntry.url.split("/");
@@ -2031,7 +2347,12 @@ function buildPoolModelConfigForStep(inputs) {
2031
2347
  outputProbabilities: modelEntry.outputProbabilities,
2032
2348
  postprocessor: resolveEffectivePostprocessorForEntry(def, modelEntry),
2033
2349
  confidence: resolvePoolConfidenceFloor(stepId, def.defaultConfidence),
2034
- nmsIouThreshold: inputs.nmsIouThreshold ?? .45,
2350
+ nmsIouThreshold: resolvePoolDecodeSettings({
2351
+ def,
2352
+ modelEntry,
2353
+ settings: inputs.stepSettings,
2354
+ fallbackNmsIouThreshold: inputs.nmsIouThreshold
2355
+ }).nmsIouThreshold,
2035
2356
  labels,
2036
2357
  charset: def.charset,
2037
2358
  plateRegion: def.plateRegion,
@@ -6951,7 +7272,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6951
7272
  this.executor = executor;
6952
7273
  const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
6953
7274
  const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6954
- const needed = enabledSteps.filter((s) => !dispatchFactory.isLoadedWithModel(s.addonId, s.modelId));
7275
+ const needed = enabledSteps.filter((s) => dispatchFactory.needsPoolUpdate(s));
6955
7276
  if (needed.length > 0) {
6956
7277
  this.log.info("Benchmark: models to load", { meta: {
6957
7278
  count: needed.length,
@@ -7392,7 +7713,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7392
7713
  const needed = [];
7393
7714
  for (const step of allEnabled) {
7394
7715
  if (!step.enabled) continue;
7395
- if (factory?.isLoadedWithModel(step.addonId, step.modelId)) continue;
7716
+ if (factory !== null && !factory.needsPoolUpdate(step)) continue;
7396
7717
  needed.push(step);
7397
7718
  }
7398
7719
  if (needed.length === 0) return;
@@ -7551,7 +7872,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7551
7872
  try {
7552
7873
  const factory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
7553
7874
  if (!factory) throw new Error("runPipelineBatch: factory not initialised");
7554
- if (enabledSteps.filter((s) => !factory.isLoadedWithModel(s.addonId, s.modelId)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
7875
+ if (enabledSteps.filter((s) => factory.needsPoolUpdate(s)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
7555
7876
  const canFastPath = singleRoot && allRaw && uniformDims && factory.supportsBatch();
7556
7877
  this.log.info("runPipelineBatch path decision", { meta: {
7557
7878
  phase: "batch",
@@ -8260,7 +8581,13 @@ function buildSchemaSlots(formats, modelsDir, customByStep) {
8260
8581
  if (availableModels.length === 0) continue;
8261
8582
  const slot = step.slot;
8262
8583
  if (!slotMap.has(slot)) slotMap.set(slot, []);
8263
- const configSchema = pipelineStep.getConfigSchema();
8584
+ const customIds = new Set((customByStep?.get(step.id) ?? []).map((m) => m.id));
8585
+ const configSchema = annotateConfigSchemaForModels({
8586
+ def: step,
8587
+ configSchema: pipelineStep.getConfigSchema(),
8588
+ models: availableModels,
8589
+ customModelIds: customIds
8590
+ });
8264
8591
  slotMap.get(slot).push({
8265
8592
  id: step.id,
8266
8593
  name: step.name,