@camstack/addon-pipeline 1.2.104 → 1.2.105

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-UzUMfVj1.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-D9sltFoR.mjs → dist-BSWr9Qg8.mjs} +66 -5
  7. package/dist/{dist-gLAVhbvO.js → dist-DVMPCqaj.js} +66 -5
  8. package/dist/{event-loop-stall-monitor-BUhYY39J.js → event-loop-stall-monitor-BazdZ0g0.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-BS_lPX6H.mjs → event-loop-stall-monitor-BeQHivuY.mjs} +1 -1
  10. package/dist/{lazy-sharp-OiAUva0g.js → lazy-sharp-Du1-XaNP.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-CiNyC-wv.js} +1 -1
  16. package/dist/{process-memory-Dw7-9jCI.mjs → process-memory-DFL51gCx.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-pkzutnza.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-B1i1rtBB.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-Clcy8PvY.mjs} +1 -1
  29. package/dist/{worker-protocol-D_0q_4Le.js → worker-protocol-pUUt-yhh.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,8 +1,8 @@
1
1
  import { n as __require } from "../chunk-DnnnRqeS.mjs";
2
- import { $ as pipelineExecutorCapability, B as detectionPipelineCapability, Ct as hydrateSchema, Dt as nodePin, E as YAMNET_TO_MACRO, F as defaultDeviceFor$1, Ht as union, Nt as array, Ot as parseJsonUnknown, St as createEvent, U as enumerateInferenceDevices, Ut as EventCategory, Vt as string, W as evaluateZoneRules, X as pickClusterStepModels, at as resolvePoolMemoryPolicy, b as PoolMemoryWatchdog, c as DEFAULT_CLUSTER_STEP_MODELS, f as DEVICE_BACKEND_TO_FORMAT, ft as errMsg, jt as sleep, rt as resolveClusterStepModelId, st as runtimeDevices$1, t as APPLE_SA_TO_MACRO, ut as supportedRuntimes$1, vt as BaseAddon, zt as object } from "../dist-D9sltFoR.mjs";
3
- import { a as getDefaultModelForFormat, c as getStepDefinition, i as ALL_STEPS, l as resolveModelForFormat, n as startEventLoopStallMonitor, o as getDefaultModelForFormatFromDef, r as ALL_PIPELINE_STEPS, s as getStep, u as landmarkPrecisionVerdict } from "../event-loop-stall-monitor-BS_lPX6H.mjs";
2
+ import { $ as pipelineExecutorCapability, B as detectionPipelineCapability, Ct as hydrateSchema, Dt as nodePin, E as YAMNET_TO_MACRO, F as defaultDeviceFor$1, Ht as union, Nt as array, Ot as parseJsonUnknown, St as createEvent, U as enumerateInferenceDevices, Ut as EventCategory, Vt as string, W as evaluateZoneRules, X as pickClusterStepModels, at as resolvePoolMemoryPolicy, b as PoolMemoryWatchdog, c as DEFAULT_CLUSTER_STEP_MODELS, f as DEVICE_BACKEND_TO_FORMAT, ft as errMsg, jt as sleep, rt as resolveClusterStepModelId, st as runtimeDevices$1, t as APPLE_SA_TO_MACRO, ut as supportedRuntimes$1, vt as BaseAddon, zt as object } from "../dist-BSWr9Qg8.mjs";
3
+ import { a as getDefaultModelForFormat, c as getStepDefinition, i as ALL_STEPS, l as resolveModelForFormat, n as startEventLoopStallMonitor, o as getDefaultModelForFormatFromDef, r as ALL_PIPELINE_STEPS, s as getStep, u as landmarkPrecisionVerdict } from "../event-loop-stall-monitor-BeQHivuY.mjs";
4
4
  import { t as getSharp } from "../lazy-sharp-6oymT_yf.mjs";
5
- import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-Dw7-9jCI.mjs";
5
+ import { n as pickNodePlatformArch, t as readProcessMemory } from "../process-memory-DFL51gCx.mjs";
6
6
  import { a as ensureModel, o as isModelDownloaded, r as deleteModelFromDisk } from "../addon-utils-A2S9D7pu.mjs";
7
7
  import sharp from "sharp";
8
8
  import { spawn } from "node:child_process";
@@ -1069,6 +1069,25 @@ var SharedInferencePool = class {
1069
1069
  })));
1070
1070
  for (const resp of responses) if (resp.status !== "ok") throw new Error(`Failed to unload model at index ${index}: ${resp.error ?? "unknown"}`);
1071
1071
  }
1072
+ /**
1073
+ * Retune a LOADED slot's decode-scoped settings without touching its
1074
+ * inference session — the narrow counterpart of `replaceModel`.
1075
+ *
1076
+ * `replace` is `unload` + `load`: it re-instantiates the session (~1.4s on
1077
+ * coreml, and an OpenVINO recompile) and stalls every live camera routed to
1078
+ * this pool. An operator moving the NMS slider must not cost that, and must
1079
+ * not touch any other slot: the Python side applies the patch to `index`
1080
+ * only, and refuses any key outside its reconfigurable allow-list rather
1081
+ * than accepting a `path` change that would silently not reload.
1082
+ */
1083
+ async reconfigureModel(index, settings) {
1084
+ const responses = await Promise.all(this.workers.map((w) => w.sendCommand({
1085
+ cmd: "reconfigure",
1086
+ index,
1087
+ config: { nmsIouThreshold: settings.nmsIouThreshold }
1088
+ })));
1089
+ for (const resp of responses) if (resp.status !== "ok") throw new Error(`Failed to reconfigure model at index ${index}: ${resp.error ?? "unknown"}`);
1090
+ }
1072
1091
  async replaceModel(index, config) {
1073
1092
  const responses = await Promise.all(this.workers.map((w) => w.sendCommand({
1074
1093
  cmd: "replace",
@@ -1296,10 +1315,188 @@ function pruneUnrunnableDetailSteps(steps, isDetailStep, isUnrunnable) {
1296
1315
  };
1297
1316
  }
1298
1317
  //#endregion
1318
+ //#region src/detection-pipeline/registry/effective-postprocessor.ts
1319
+ function resolveEffectivePostprocessor(definition, modelId) {
1320
+ return resolveEffectivePostprocessorForEntry(definition, modelId ? definition.models.find((m) => m.id === modelId) : void 0);
1321
+ }
1322
+ /**
1323
+ * Entry-based variant for callers that already hold the `ModelCatalogEntry` —
1324
+ * required for CUSTOM (operator-registered) models, which are NOT in
1325
+ * `definition.models` and would otherwise silently fall back to the step
1326
+ * default decode (a Frigate `yolonas` model decoded as `yolo` produces no
1327
+ * error anywhere, just zero detections).
1328
+ */
1329
+ function resolveEffectivePostprocessorForEntry(definition, entry) {
1330
+ return entry?.postprocessor ?? definition.postprocessor;
1331
+ }
1332
+ //#endregion
1333
+ //#region src/detection-pipeline/registry/model-knob-applicability.ts
1334
+ /** Decodes that run NMS in the pipeline (and hence consult `nmsIouThreshold`).
1335
+ * Mirrors the Python postprocessors that read the knob — yolo.py,
1336
+ * yolo_seg.py, rfdetr.py, scrfd.py. */
1337
+ var NMS_CONSUMING_POSTPROCESSORS = new Set([
1338
+ "yolo",
1339
+ "yolo-seg",
1340
+ "rfdetr",
1341
+ "scrfd"
1342
+ ]);
1343
+ /**
1344
+ * Does this decode consult `nmsIouThreshold`?
1345
+ *
1346
+ * ONE predicate for the whole system: the UI disables the slider through
1347
+ * {@link annotateConfigSchemaForModels}, and the runtime refuses to forward a
1348
+ * value through `resolvePoolDecodeSettings` — both off this set. Two copies of
1349
+ * the matrix would let the UI promise an effect the pool does not deliver,
1350
+ * which is the failure D214 exists to remove.
1351
+ */
1352
+ function postprocessorConsumesNms(postprocessor) {
1353
+ return NMS_CONSUMING_POSTPROCESSORS.has(postprocessor);
1354
+ }
1355
+ var MIN_CONFIDENCE_MACRO_PREFIX = "minConfidence";
1356
+ /** `minConfidencePerson` → `person`; bare `minConfidence` → null (generic). */
1357
+ function macroOfMinConfidenceKey(key) {
1358
+ if (!key.startsWith(MIN_CONFIDENCE_MACRO_PREFIX)) return null;
1359
+ const rest = key.slice(13);
1360
+ if (rest.length === 0) return null;
1361
+ return rest.charAt(0).toLowerCase() + rest.slice(1);
1362
+ }
1363
+ /**
1364
+ * The macro classes a model's OWN labelmap can reach through the step's
1365
+ * classMap. `undefined` ⇒ the model declares no labels (catalog default
1366
+ * labelmap) — every macro of the step is reachable, nothing to annotate.
1367
+ */
1368
+ function reachableMacros(def, entry) {
1369
+ if (entry.labels.length === 0) return void 0;
1370
+ const map = def.classMap;
1371
+ if (!map) return new Set(entry.labels.map((l) => l.id));
1372
+ if (map.preserveOriginal) return void 0;
1373
+ const macros = /* @__PURE__ */ new Set();
1374
+ for (const label of entry.labels) {
1375
+ const mapped = map.mapping[label.id];
1376
+ if (mapped !== void 0) macros.add(mapped);
1377
+ }
1378
+ return macros;
1379
+ }
1380
+ /**
1381
+ * The labels a custom model declares that the step's dropping classMap
1382
+ * (`preserveOriginal: false`) removes UPSTREAM of every operator setting.
1383
+ * `null` when the model declares no labels, the step has no classMap, the map
1384
+ * preserves originals, or nothing is dropped. The engine factory logs this at
1385
+ * pool-config time — a dropped class must never be silent (CLAUDE.md logging
1386
+ * rule; the discard trail alone answers "what", not "why zero forever").
1387
+ */
1388
+ function collectClassMapDropWarning(def, entry) {
1389
+ if (entry.labels.length === 0) return null;
1390
+ const map = def.classMap;
1391
+ if (!map || map.preserveOriginal) return null;
1392
+ const unmapped = entry.labels.filter((l) => map.mapping[l.id] === void 0).map((l) => l.id);
1393
+ if (unmapped.length === 0) return null;
1394
+ return {
1395
+ unmappedLabels: unmapped,
1396
+ mappedCount: entry.labels.length - unmapped.length
1397
+ };
1398
+ }
1399
+ function toApplicability(acc) {
1400
+ const hasInapplicable = Object.keys(acc.inapplicable).length > 0;
1401
+ const hasOptions = Object.keys(acc.optionsByModel).length > 0;
1402
+ if (!hasInapplicable && !hasOptions) return void 0;
1403
+ return {
1404
+ ...hasInapplicable ? { inapplicable: acc.inapplicable } : {},
1405
+ ...hasOptions ? { optionsByModel: acc.optionsByModel } : {}
1406
+ };
1407
+ }
1408
+ function isAnnotatable(field) {
1409
+ return field.type === "slider" || field.type === "number" || field.type === "multiselect" || field.type === "select" || field.type === "boolean";
1410
+ }
1411
+ function annotateField(field, inputs) {
1412
+ if (!isAnnotatable(field)) return field;
1413
+ const { def, models, customModelIds } = inputs;
1414
+ const acc = {
1415
+ inapplicable: {},
1416
+ optionsByModel: {}
1417
+ };
1418
+ const macroKey = macroOfMinConfidenceKey(field.key);
1419
+ for (const entry of models) {
1420
+ const postprocessor = resolveEffectivePostprocessorForEntry(def, entry);
1421
+ if (field.key === "nmsIouThreshold" && !postprocessorConsumesNms(postprocessor)) {
1422
+ acc.inapplicable[entry.id] = `NMS is fused inside this model's "${postprocessor}" export — the pipeline IoU threshold is not consulted.`;
1423
+ continue;
1424
+ }
1425
+ if (field.key === "rectify" && postprocessor !== "ctc") {
1426
+ acc.inapplicable[entry.id] = `The "${postprocessor}" reader consumes the raw plate box — its crop policy never deskews, so this switch has no effect.`;
1427
+ continue;
1428
+ }
1429
+ if (macroKey !== null) {
1430
+ const macros = reachableMacros(def, entry);
1431
+ 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.`;
1432
+ continue;
1433
+ }
1434
+ if (field.key === "enabledMacroClasses" && field.type === "multiselect") {
1435
+ const macros = reachableMacros(def, entry);
1436
+ if (macros === void 0) continue;
1437
+ const reachable = field.options.filter((o) => macros.has(o.value));
1438
+ 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.";
1439
+ else if (reachable.length < field.options.length) acc.optionsByModel[entry.id] = reachable.map((o) => ({
1440
+ value: o.value,
1441
+ label: o.label
1442
+ }));
1443
+ continue;
1444
+ }
1445
+ if (field.key === "enabledClasses" && field.type === "multiselect" && customModelIds.has(entry.id) && entry.labels.length > 0) {
1446
+ acc.optionsByModel[entry.id] = entry.labels.map((l) => ({
1447
+ value: l.id,
1448
+ label: l.name
1449
+ }));
1450
+ continue;
1451
+ }
1452
+ }
1453
+ const applicability = toApplicability(acc);
1454
+ if (applicability === void 0) return field;
1455
+ return {
1456
+ ...field,
1457
+ modelApplicability: applicability
1458
+ };
1459
+ }
1460
+ /**
1461
+ * Decorate a step's config schema with per-model applicability, computed from
1462
+ * the same model union the picker offers. Fields with nothing to say are
1463
+ * returned untouched (identity), so a static-only step with fully-consuming
1464
+ * models produces a byte-identical schema.
1465
+ */
1466
+ function annotateConfigSchemaForModels(inputs) {
1467
+ return inputs.configSchema.map((field) => annotateField(field, inputs));
1468
+ }
1469
+ /**
1470
+ * A persisted settings blob is external data — it survives schema changes,
1471
+ * hand edits and migrations. Accept only a finite IoU in the open (0,1)
1472
+ * interval; anything else falls back rather than reaching the decode, where a
1473
+ * 0 would suppress every overlapping box and a 1 would suppress none.
1474
+ */
1475
+ function readIouThreshold(value) {
1476
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
1477
+ if (value <= 0 || value >= 1) return void 0;
1478
+ return value;
1479
+ }
1480
+ /** The effective decode-scoped values for a `(step, model)` pair. */
1481
+ function resolvePoolDecodeSettings(inputs) {
1482
+ const fallback = readIouThreshold(inputs.fallbackNmsIouThreshold) ?? .45;
1483
+ if (!postprocessorConsumesNms(resolveEffectivePostprocessorForEntry(inputs.def, inputs.modelEntry))) return { nmsIouThreshold: fallback };
1484
+ return { nmsIouThreshold: readIouThreshold(inputs.settings?.["nmsIouThreshold"]) ?? fallback };
1485
+ }
1486
+ /** What a built pool config actually carries — the state the slot is IN after
1487
+ * a `load`, so the manager's diff is anchored to the command it sent, never
1488
+ * to a second derivation of the same inputs. */
1489
+ function poolDecodeSettingsOf(config) {
1490
+ return { nmsIouThreshold: config.nmsIouThreshold ?? .45 };
1491
+ }
1492
+ function poolDecodeSettingsEqual(a, b) {
1493
+ return a.nmsIouThreshold === b.nmsIouThreshold;
1494
+ }
1495
+ //#endregion
1299
1496
  //#region src/detection-pipeline/engine/pipeline-model-manager.ts
1300
1497
  var PipelineModelManager = class {
1301
1498
  pool;
1302
- buildPoolConfig;
1499
+ source;
1303
1500
  /** stepId → modelId → loaded entry. */
1304
1501
  loaded = /* @__PURE__ */ new Map();
1305
1502
  /** stepId → currently-active modelId (the runtime choice). */
@@ -1308,9 +1505,9 @@ var PipelineModelManager = class {
1308
1505
  lruClock = 0;
1309
1506
  log;
1310
1507
  maxModelsPerStep;
1311
- constructor(pool, buildPoolConfig, logger, options) {
1508
+ constructor(pool, source, logger, options) {
1312
1509
  this.pool = pool;
1313
- this.buildPoolConfig = buildPoolConfig;
1510
+ this.source = source;
1314
1511
  this.log = logger;
1315
1512
  this.maxModelsPerStep = options?.maxModelsPerStep ?? 4;
1316
1513
  }
@@ -1334,7 +1531,7 @@ var PipelineModelManager = class {
1334
1531
  } });
1335
1532
  }
1336
1533
  for (const added of diff.added) {
1337
- await this.ensureLoaded(added.addonId, added.modelId);
1534
+ await this.ensureLoaded(added.addonId, added.modelId, added.settings);
1338
1535
  this.activeByStep.set(added.addonId, added.modelId);
1339
1536
  this.log.info("Step loaded", { meta: {
1340
1537
  step: added.addonId,
@@ -1343,7 +1540,7 @@ var PipelineModelManager = class {
1343
1540
  } });
1344
1541
  }
1345
1542
  for (const { step, oldModelId } of diff.modelChanged) {
1346
- await this.ensureLoaded(step.addonId, step.modelId);
1543
+ await this.ensureLoaded(step.addonId, step.modelId, step.settings);
1347
1544
  this.activeByStep.set(step.addonId, step.modelId);
1348
1545
  this.log.info("Active model switched", { meta: {
1349
1546
  step: step.addonId,
@@ -1352,6 +1549,7 @@ var PipelineModelManager = class {
1352
1549
  } });
1353
1550
  await this.enforceLruCap(step.addonId);
1354
1551
  }
1552
+ for (const { entry, step } of diff.unchanged) await this.reconcileDecode(entry, step.settings);
1355
1553
  }
1356
1554
  /**
1357
1555
  * Get an `IInferenceEngine` handle. Without `modelId`, returns the
@@ -1373,6 +1571,22 @@ var PipelineModelManager = class {
1373
1571
  isLoadedWithModel(stepId, modelId) {
1374
1572
  return this.loaded.get(stepId)?.has(modelId) ?? false;
1375
1573
  }
1574
+ /**
1575
+ * Does the pool NOT yet reflect this step? True when the (step, model) pair
1576
+ * is absent, and also when it is resident with different decode-scoped
1577
+ * settings — the case `isLoadedWithModel` cannot see.
1578
+ *
1579
+ * This is the per-dispatch skip gate: callers that short-circuit on
1580
+ * "already loaded" were the reason a saved threshold never reached a
1581
+ * running pool. Cheap by contract — `resolveDecode` never touches the fs.
1582
+ */
1583
+ needsPoolUpdate(step) {
1584
+ const entry = this.loaded.get(step.addonId)?.get(step.modelId);
1585
+ if (!entry) return true;
1586
+ const desired = this.source.resolveDecode(step.addonId, step.modelId, step.settings);
1587
+ if (desired === null) return true;
1588
+ return !poolDecodeSettingsEqual(entry.decode, desired);
1589
+ }
1376
1590
  /** ModelId currently designated as active for the step. */
1377
1591
  getLoadedModelId(stepId) {
1378
1592
  return this.activeByStep.get(stepId);
@@ -1424,8 +1638,7 @@ var PipelineModelManager = class {
1424
1638
  */
1425
1639
  async loadAdditional(steps) {
1426
1640
  for (const step of steps) {
1427
- if (this.isLoadedWithModel(step.addonId, step.modelId)) continue;
1428
- await this.ensureLoaded(step.addonId, step.modelId);
1641
+ await this.ensureLoaded(step.addonId, step.modelId, step.settings);
1429
1642
  if (!this.activeByStep.has(step.addonId)) this.activeByStep.set(step.addonId, step.modelId);
1430
1643
  }
1431
1644
  }
@@ -1434,7 +1647,7 @@ var PipelineModelManager = class {
1434
1647
  * Honors `maxModelsPerStep` — evicts a non-active LRU variant first
1435
1648
  * when the cap would otherwise be exceeded.
1436
1649
  */
1437
- async ensureLoaded(stepId, modelId) {
1650
+ async ensureLoaded(stepId, modelId, settings) {
1438
1651
  let perStep = this.loaded.get(stepId);
1439
1652
  if (!perStep) {
1440
1653
  perStep = /* @__PURE__ */ new Map();
@@ -1443,6 +1656,7 @@ var PipelineModelManager = class {
1443
1656
  const existing = perStep.get(modelId);
1444
1657
  if (existing) {
1445
1658
  this.touch(existing);
1659
+ await this.reconcileDecode(existing, settings);
1446
1660
  return existing;
1447
1661
  }
1448
1662
  while (perStep.size >= this.maxModelsPerStep) {
@@ -1457,11 +1671,13 @@ var PipelineModelManager = class {
1457
1671
  } });
1458
1672
  }
1459
1673
  const index = this.pool.allocateIndex();
1460
- const config = this.buildPoolConfig(stepId, modelId);
1674
+ const config = this.source.buildConfig(stepId, modelId, settings);
1675
+ const decode = poolDecodeSettingsOf(config);
1461
1676
  this.log.info("Loading step variant", { meta: {
1462
1677
  step: stepId,
1463
1678
  modelId,
1464
- poolIndex: index
1679
+ poolIndex: index,
1680
+ ...decode
1465
1681
  } });
1466
1682
  const { loadMs } = await this.pool.loadModel(index, config);
1467
1683
  this.log.info("Step variant loaded", { meta: {
@@ -1474,11 +1690,71 @@ var PipelineModelManager = class {
1474
1690
  stepId,
1475
1691
  modelId,
1476
1692
  poolIndex: index,
1477
- lruTick: ++this.lruClock
1693
+ lruTick: ++this.lruClock,
1694
+ decode
1478
1695
  };
1479
1696
  perStep.set(modelId, entry);
1480
1697
  return entry;
1481
1698
  }
1699
+ /**
1700
+ * Push a retuned decode setting into ONE already-loaded slot.
1701
+ *
1702
+ * Scope is the point: `reconfigure` names a single pool index, so a slider
1703
+ * moved on `object-detection/yolo26n` never disturbs the face detector, the
1704
+ * classifiers, or the same step's warm benchmark variants — and it never
1705
+ * costs a session reload (see `SharedInferencePool.reconfigureModel`).
1706
+ *
1707
+ * A no-op when nothing moved: this runs on the per-dispatch path, so the
1708
+ * common case must send zero commands.
1709
+ */
1710
+ async reconcileDecode(entry, settings) {
1711
+ const desired = this.source.resolveDecode(entry.stepId, entry.modelId, settings);
1712
+ if (desired === null) return;
1713
+ if (poolDecodeSettingsEqual(entry.decode, desired)) return;
1714
+ const previous = entry.decode;
1715
+ await this.pool.reconfigureModel(entry.poolIndex, desired);
1716
+ entry.decode = desired;
1717
+ this.log.info("Decode settings retuned in place — no model reload", { meta: {
1718
+ step: entry.stepId,
1719
+ modelId: entry.modelId,
1720
+ poolIndex: entry.poolIndex,
1721
+ previousNmsIouThreshold: previous.nmsIouThreshold,
1722
+ ...desired
1723
+ } });
1724
+ this.reportContention(entry, previous, desired);
1725
+ }
1726
+ /** Decode values each (step, model) pair has already run, so a value
1727
+ * RETURNING can be told apart from a value being changed. */
1728
+ decodeHistory = /* @__PURE__ */ new Map();
1729
+ contentionReported = /* @__PURE__ */ new Set();
1730
+ /**
1731
+ * A pool slot is NODE-level; the settings driving it arrive per dispatch, so
1732
+ * two cameras with different per-camera overrides for the same (step, model)
1733
+ * pull the same slot back and forth. That is not expressible — one of them is
1734
+ * always decoding with the other's threshold — and it is invisible from the
1735
+ * outside, so it is reported the first time a pair flip-flops BACK to a value
1736
+ * it already ran. One steady retune (the operator moving a slider) never
1737
+ * trips it; alternating writers do, immediately.
1738
+ */
1739
+ reportContention(entry, previous, desired) {
1740
+ const key = `${entry.stepId}::${entry.modelId}`;
1741
+ let seen = this.decodeHistory.get(key);
1742
+ if (!seen) {
1743
+ seen = new Set([previous.nmsIouThreshold]);
1744
+ this.decodeHistory.set(key, seen);
1745
+ }
1746
+ const returning = seen.has(desired.nmsIouThreshold);
1747
+ seen.add(desired.nmsIouThreshold);
1748
+ if (!returning || this.contentionReported.has(key)) return;
1749
+ this.contentionReported.add(key);
1750
+ 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: {
1751
+ step: entry.stepId,
1752
+ modelId: entry.modelId,
1753
+ poolIndex: entry.poolIndex,
1754
+ valuesSeen: [...seen].sort((a, b) => a - b),
1755
+ hint: "a per-camera override of a decode-scoped knob cannot be honoured on a shared pool — keep this knob at the node level"
1756
+ } });
1757
+ }
1482
1758
  async unloadEntry(entry) {
1483
1759
  await this.pool.unloadModel(entry.poolIndex);
1484
1760
  const perStep = this.loaded.get(entry.stepId);
@@ -1544,7 +1820,10 @@ var PipelineModelManager = class {
1544
1820
  step: desired,
1545
1821
  oldModelId: activeModelId
1546
1822
  });
1547
- else unchanged.push(activeEntry);
1823
+ else unchanged.push({
1824
+ entry: activeEntry,
1825
+ step: desired
1826
+ });
1548
1827
  }
1549
1828
  for (const [stepId, step] of desiredMap) if (!this.activeByStep.has(stepId)) added.push(step);
1550
1829
  return {
@@ -1556,21 +1835,6 @@ var PipelineModelManager = class {
1556
1835
  }
1557
1836
  };
1558
1837
  //#endregion
1559
- //#region src/detection-pipeline/registry/effective-postprocessor.ts
1560
- function resolveEffectivePostprocessor(definition, modelId) {
1561
- return resolveEffectivePostprocessorForEntry(definition, modelId ? definition.models.find((m) => m.id === modelId) : void 0);
1562
- }
1563
- /**
1564
- * Entry-based variant for callers that already hold the `ModelCatalogEntry` —
1565
- * required for CUSTOM (operator-registered) models, which are NOT in
1566
- * `definition.models` and would otherwise silently fall back to the step
1567
- * default decode (a Frigate `yolonas` model decoded as `yolo` produces no
1568
- * error anywhere, just zero detections).
1569
- */
1570
- function resolveEffectivePostprocessorForEntry(definition, entry) {
1571
- return entry?.postprocessor ?? definition.postprocessor;
1572
- }
1573
- //#endregion
1574
1838
  //#region src/detection-pipeline/engine/resolve-device-engine.ts
1575
1839
  /**
1576
1840
  * Resolve a Phase-1 device key (`<backend>:<device>`, or bare `cpu`) into the
@@ -1837,6 +2101,14 @@ var EngineFactory = class {
1837
2101
  return this.poolManager?.isLoadedWithModel(stepId, modelId) ?? false;
1838
2102
  }
1839
2103
  /**
2104
+ * Does the pool still owe this step something — a load, or a retune of its
2105
+ * decode-scoped settings? The skip gate every dispatch path must use instead
2106
+ * of `isLoadedWithModel`, which cannot see a changed threshold (D214).
2107
+ */
2108
+ needsPoolUpdate(step) {
2109
+ return this.poolManager?.needsPoolUpdate(step) ?? true;
2110
+ }
2111
+ /**
1840
2112
  * List every loaded (stepId, modelId) variant — the pool surfaces each
1841
2113
  * warm slot independently (including bench overrides).
1842
2114
  */
@@ -1972,19 +2244,57 @@ var EngineFactory = class {
1972
2244
  numWorkers,
1973
2245
  ...this.opts.engine.device ? { device: this.opts.engine.device } : {}
1974
2246
  });
1975
- this.poolManager = new PipelineModelManager(this.pool, (stepId, modelId) => this.buildPoolModelConfig(stepId, modelId, poolRuntime), this.log.child("model-mgr"));
2247
+ this.poolManager = new PipelineModelManager(this.pool, {
2248
+ buildConfig: (stepId, modelId, settings) => this.buildPoolModelConfig(stepId, modelId, poolRuntime, settings),
2249
+ resolveDecode: (stepId, modelId, settings) => this.resolveDecode(stepId, modelId, settings)
2250
+ }, this.log.child("model-mgr"));
1976
2251
  await this.pool.initialize([]);
1977
2252
  await this.poolManager.applyConfig(steps);
1978
2253
  }
1979
- buildPoolModelConfig(stepId, modelId, poolRuntime) {
2254
+ /** (step, model) pairs whose classMap-drop warning already logged — the
2255
+ * pool reloads models on every applyConfig; the fact doesn't change. */
2256
+ classMapDropLogged = /* @__PURE__ */ new Set();
2257
+ /**
2258
+ * The fs-free half of {@link PoolModelSource} — the effective decode-scoped
2259
+ * settings for a `(step, model, settings)` triple. Runs on the per-dispatch
2260
+ * path, so it must resolve the catalog entry and nothing more; the label
2261
+ * file, the format build and the model path are load-time concerns.
2262
+ *
2263
+ * `null` when the pair resolves to no model at all — the caller routes that
2264
+ * to `buildConfig`, which throws the message naming the step and the model.
2265
+ */
2266
+ resolveDecode(stepId, modelId, settings) {
2267
+ const def = getStepDefinition(stepId);
2268
+ const modelEntry = def.models.find((m) => m.id === modelId) ?? this.opts.resolveCustomModel?.(stepId, modelId);
2269
+ if (!modelEntry) return null;
2270
+ return resolvePoolDecodeSettings({
2271
+ def,
2272
+ modelEntry,
2273
+ settings,
2274
+ fallbackNmsIouThreshold: this.opts.nmsIouThreshold
2275
+ });
2276
+ }
2277
+ buildPoolModelConfig(stepId, modelId, poolRuntime, stepSettings) {
1980
2278
  return buildPoolModelConfigForStep({
1981
2279
  stepId,
1982
2280
  modelId,
1983
2281
  poolRuntime,
1984
2282
  modelsDir: this.opts.modelsDir,
1985
2283
  nmsIouThreshold: this.opts.nmsIouThreshold,
2284
+ stepSettings,
1986
2285
  device: this.opts.engine.device,
1987
- resolveCustomModel: this.opts.resolveCustomModel
2286
+ resolveCustomModel: this.opts.resolveCustomModel,
2287
+ onClassMapDrop: ({ stepId: sid, modelId: mid, unmappedLabels }) => {
2288
+ const key = `${sid}::${mid}`;
2289
+ if (this.classMapDropLogged.has(key)) return;
2290
+ this.classMapDropLogged.add(key);
2291
+ this.log.warn("Model labels dropped by step classMap — unreachable by any setting", { meta: {
2292
+ step: sid,
2293
+ modelId: mid,
2294
+ unmappedLabels: [...unmappedLabels],
2295
+ hint: "these classes are removed before enabledMacroClasses / minConfidence* run; only labels in the step classMap are tracked"
2296
+ } });
2297
+ }
1988
2298
  });
1989
2299
  }
1990
2300
  };
@@ -2003,6 +2313,12 @@ function buildPoolModelConfigForStep(inputs) {
2003
2313
  if (!modelEntry) throw new Error(`Model "${modelId}" not found in step "${stepId}" catalog (static or custom registry)`);
2004
2314
  const format = RUNTIME_TO_FORMAT[poolRuntime];
2005
2315
  if (!format) throw new Error(`No format mapping for pool runtime "${poolRuntime}"`);
2316
+ const dropWarning = collectClassMapDropWarning(def, modelEntry);
2317
+ if (dropWarning !== null) inputs.onClassMapDrop?.({
2318
+ stepId,
2319
+ modelId,
2320
+ unmappedLabels: dropWarning.unmappedLabels
2321
+ });
2006
2322
  const formatEntry = modelEntry.formats[format];
2007
2323
  if (!formatEntry) throw new Error(`Model "${modelId}" has no ${format} format. Available: ${Object.keys(modelEntry.formats).join(", ")}`);
2008
2324
  const urlParts = formatEntry.url.split("/");
@@ -2024,7 +2340,12 @@ function buildPoolModelConfigForStep(inputs) {
2024
2340
  outputProbabilities: modelEntry.outputProbabilities,
2025
2341
  postprocessor: resolveEffectivePostprocessorForEntry(def, modelEntry),
2026
2342
  confidence: resolvePoolConfidenceFloor(stepId, def.defaultConfidence),
2027
- nmsIouThreshold: inputs.nmsIouThreshold ?? .45,
2343
+ nmsIouThreshold: resolvePoolDecodeSettings({
2344
+ def,
2345
+ modelEntry,
2346
+ settings: inputs.stepSettings,
2347
+ fallbackNmsIouThreshold: inputs.nmsIouThreshold
2348
+ }).nmsIouThreshold,
2028
2349
  labels,
2029
2350
  charset: def.charset,
2030
2351
  plateRegion: def.plateRegion,
@@ -6944,7 +7265,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
6944
7265
  this.executor = executor;
6945
7266
  const dispatchEngine = dispatchDeviceKey ? resolveDeviceEngine(dispatchDeviceKey) : runEngine;
6946
7267
  const dispatchFactory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
6947
- const needed = enabledSteps.filter((s) => !dispatchFactory.isLoadedWithModel(s.addonId, s.modelId));
7268
+ const needed = enabledSteps.filter((s) => dispatchFactory.needsPoolUpdate(s));
6948
7269
  if (needed.length > 0) {
6949
7270
  this.log.info("Benchmark: models to load", { meta: {
6950
7271
  count: needed.length,
@@ -7385,7 +7706,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7385
7706
  const needed = [];
7386
7707
  for (const step of allEnabled) {
7387
7708
  if (!step.enabled) continue;
7388
- if (factory?.isLoadedWithModel(step.addonId, step.modelId)) continue;
7709
+ if (factory !== null && !factory.needsPoolUpdate(step)) continue;
7389
7710
  needed.push(step);
7390
7711
  }
7391
7712
  if (needed.length === 0) return;
@@ -7544,7 +7865,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
7544
7865
  try {
7545
7866
  const factory = dispatchDeviceKey ? await this.resolveDeviceFactory(dispatchDeviceKey) : this.engineFactory;
7546
7867
  if (!factory) throw new Error("runPipelineBatch: factory not initialised");
7547
- if (enabledSteps.filter((s) => !factory.isLoadedWithModel(s.addonId, s.modelId)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
7868
+ if (enabledSteps.filter((s) => factory.needsPoolUpdate(s)).length > 0) await this.ensureModelsForSteps(benchmarkSteps, factory, resolveFormat);
7548
7869
  const canFastPath = singleRoot && allRaw && uniformDims && factory.supportsBatch();
7549
7870
  this.log.info("runPipelineBatch path decision", { meta: {
7550
7871
  phase: "batch",
@@ -8253,7 +8574,13 @@ function buildSchemaSlots(formats, modelsDir, customByStep) {
8253
8574
  if (availableModels.length === 0) continue;
8254
8575
  const slot = step.slot;
8255
8576
  if (!slotMap.has(slot)) slotMap.set(slot, []);
8256
- const configSchema = pipelineStep.getConfigSchema();
8577
+ const customIds = new Set((customByStep?.get(step.id) ?? []).map((m) => m.id));
8578
+ const configSchema = annotateConfigSchemaForModels({
8579
+ def: step,
8580
+ configSchema: pipelineStep.getConfigSchema(),
8581
+ models: availableModels,
8582
+ customModelIds: customIds
8583
+ });
8257
8584
  slotMap.get(slot).push({
8258
8585
  id: step.id,
8259
8586
  name: step.name,
@@ -20764,6 +20764,12 @@ var CameraStatusSchema = object({
20764
20764
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
20765
20765
  fetchedAt: number()
20766
20766
  });
20767
+ var InferenceDeviceExclusionReasonSchema = _enum([
20768
+ "disabled",
20769
+ "unavailable",
20770
+ "cannot-host-camera-root",
20771
+ "accelerator-preferred"
20772
+ ]);
20767
20773
  var NodeInferenceDeviceSchema = object({
20768
20774
  /** Stable per-node device key, e.g. `openvino:npu`, `edgetpu:usb`, `cpu`. */
20769
20775
  key: string(),
@@ -20794,7 +20800,17 @@ var NodeInferenceDeviceSchema = object({
20794
20800
  * available per format; this is the stored selection that becomes the
20795
20801
  * default for EVERY camera landing on this accelerator.
20796
20802
  */
20797
- steps: record(string(), DeviceStepConfigSchema).optional()
20803
+ steps: record(string(), DeviceStepConfigSchema).optional(),
20804
+ /**
20805
+ * `null` when the device IS a camera-root candidate on this node; otherwise
20806
+ * the reason the dispatcher drops it. Computed by the SAME
20807
+ * `resolveInferenceDeviceEligibility` the dispatcher runs, so this view can
20808
+ * never disagree with the election — deriving it in the UI from
20809
+ * `enabled`/`available` would silently miss `cannot-host-camera-root` (needs
20810
+ * the node's model catalog) and `accelerator-preferred` (needs the node-wide
20811
+ * "an accelerator is serving" predicate).
20812
+ */
20813
+ exclusion: InferenceDeviceExclusionReasonSchema.nullable()
20798
20814
  });
20799
20815
  var NodeInferenceDevicesSchema = object({
20800
20816
  nodeId: string(),
@@ -24714,7 +24730,12 @@ var ListResultSchema = object({
24714
24730
  probedAt: number()
24715
24731
  });
24716
24732
  var PreferredSchema = LocalInterfaceSchema.nullable();
24717
- var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24733
+ /**
24734
+ * Candidate base URL for the SDK to race on connect. Order matters —
24735
+ * the SDK should attempt these top-to-bottom with a short per-candidate
24736
+ * timeout (e.g. 1500ms) and cache the winner for the session.
24737
+ */
24738
+ var ConnectionEndpointSchema = object({
24718
24739
  /** Operator-facing label (e.g. "LAN — en0", "Public tunnel"). */
24719
24740
  label: string(),
24720
24741
  /** Fully-formed base URL with scheme + host + port. */
@@ -24757,7 +24778,42 @@ var GetConnectionEndpointsResultSchema = object({ endpoints: array(object({
24757
24778
  * ordering between polls.
24758
24779
  */
24759
24780
  priority: number()
24760
- })).readonly() });
24781
+ });
24782
+ /**
24783
+ * Where the advertised local port came from. Ordered most → least
24784
+ * authoritative, and the whole point of returning it: a client must be able to
24785
+ * tell a FACT about the hub's socket from an echo of its own guess.
24786
+ */
24787
+ var LocalPortSourceEnum = _enum([
24788
+ "server-config",
24789
+ "server-env",
24790
+ "caller-hint",
24791
+ "default"
24792
+ ]);
24793
+ /** The port every LAN/loopback `baseUrl` in the same result was built with. */
24794
+ var AdvertisedLocalPortSchema = object({
24795
+ port: number().int().min(1).max(65535),
24796
+ source: LocalPortSourceEnum
24797
+ });
24798
+ var GetConnectionEndpointsResultSchema = object({
24799
+ endpoints: array(ConnectionEndpointSchema).readonly(),
24800
+ /**
24801
+ * The port the hub built the LAN/loopback URLs with, and where that number
24802
+ * came from.
24803
+ *
24804
+ * Returned rather than merely applied, because "the URL is right" and "the
24805
+ * client can KNOW the URL is right" are different properties. A client that
24806
+ * only sees a corrected URL cannot distinguish a hub that fixed the port from
24807
+ * a hub that echoed the port the client sent, so it cannot decide whether to
24808
+ * race the candidate or discard it. With `source` it can: anything but
24809
+ * `caller-hint` is the hub's own socket.
24810
+ *
24811
+ * Absent on hubs predating this field — a client that finds it missing is
24812
+ * talking to an echoing hub and must degrade exactly as it does for
24813
+ * `caller-hint`.
24814
+ */
24815
+ localPort: AdvertisedLocalPortSchema
24816
+ });
24761
24817
  /**
24762
24818
  * The chosen outbound endpoint for notification artifacts. `baseUrl: null` =
24763
24819
  * AUTO (resolved from the candidate ranking at send time); `resolved` reports
@@ -24778,8 +24834,13 @@ var AllowedAddressesSchema = object({
24778
24834
  */
24779
24835
  addresses: array(string()).readonly() });
24780
24836
  method(_void(), ListResultSchema), method(_void(), PreferredSchema), method(object({
24781
- /** Local hub HTTP port to use in base URLs. */
24782
- port: number().int().min(1).max(65535),
24837
+ /**
24838
+ * LEGACY HINT — do not send from new code. Kept optional so clients
24839
+ * written against the echoing contract keep working; the hub uses it
24840
+ * only when it cannot read its own port, and says so via
24841
+ * `localPort.source === 'caller-hint'`.
24842
+ */
24843
+ port: number().int().min(1).max(65535).optional(),
24783
24844
  /** Include `http(s)://127.0.0.1:<port>` as the lowest-priority
24784
24845
  * candidate. Default `true`. */
24785
24846
  includeLoopback: boolean().optional(),