@camstack/addon-pipeline 1.1.43 → 1.1.44

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 (36) hide show
  1. package/dist/audio-analyzer/index.js +5 -4
  2. package/dist/audio-analyzer/index.mjs +1 -1
  3. package/dist/chunk-D6vf50IK.js +28 -0
  4. package/dist/detection-pipeline/index.js +526 -109
  5. package/dist/detection-pipeline/index.mjs +520 -104
  6. package/dist/{dist-GFd8M6KO.mjs → dist-CgBGV988.mjs} +214 -186
  7. package/dist/{dist-NvwN60Fq.js → dist-CySHUkXY.js} +219 -185
  8. package/dist/{model-download-service-C-IHWnXx-DxM2DSns.js → model-download-service-C-IHWnXx-BLXzxpRB.js} +3 -29
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/pipeline-runner/index.js +122 -287
  12. package/dist/pipeline-runner/index.mjs +118 -285
  13. package/dist/recorder/index.js +60 -20
  14. package/dist/recorder/index.mjs +57 -18
  15. package/dist/{frame-handle-plane-E_AsR77T.mjs → remote-source-plane-B4hRwchu.mjs} +219 -2
  16. package/dist/{frame-handle-plane-B3Fxww8H.js → remote-source-plane-D9m0z1N9.js} +236 -1
  17. package/dist/session-decode/decode-worker-child.js +86 -2
  18. package/dist/session-decode/decode-worker-child.mjs +83 -2
  19. package/dist/stream-broker/_stub.js +550 -523
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CeielP5P.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-G6wtUb3G.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Dlagq9X_.mjs +26 -0
  22. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BUpF966l.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-DmjqskOR.mjs → hostInit-BSzP3BIj.mjs} +2 -2
  24. package/dist/stream-broker/index.js +746 -135
  25. package/dist/stream-broker/index.mjs +737 -127
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/embed-dist/assets/{MaskShapeCanvas-DI4BY7W2-CEsPjwzT.js → MaskShapeCanvas-DI4BY7W2-D2ML5TPi.js} +1 -1
  28. package/embed-dist/assets/{MotionZonesSettings-NcxxQN8r-D43fIRgB.js → MotionZonesSettings-NcxxQN8r-TTWGGaqv.js} +1 -1
  29. package/embed-dist/assets/{PrivacyMaskSettings-APgPLF7p-C3wJxG8V.js → PrivacyMaskSettings-APgPLF7p-CJqHU2nU.js} +1 -1
  30. package/embed-dist/assets/index-C1oaL9s7.js +81 -0
  31. package/embed-dist/assets/{index-CSFtK41z.css → index-CA4kv43T.css} +1 -1
  32. package/embed-dist/index.html +2 -2
  33. package/package.json +2 -2
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqJvWBKS.mjs +0 -26
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-dGka8yZY.mjs +0 -26
  36. package/embed-dist/assets/index-CX-rILhw.js +0 -81
@@ -1,5 +1,5 @@
1
1
  import { t as __require } from "../chunk-BdkLduGY.mjs";
2
- import { H as createEvent, I as errMsg, K as parseJsonUnknown, L as BaseAddon, O as pipelineExecutorCapability, P as supportedRuntimes$1, S as hfModelUrl, U as hydrateSchema, V as EventCategory, Y as sleep, a as COCO_TO_MACRO, b as detectionPipelineCapability, f as YAMNET_TO_MACRO, i as COCO_80_LABELS, j as runtimeDevices$1, r as AUDIO_MACRO_LABELS, t as APPLE_SA_TO_MACRO, v as defaultDeviceFor$1, x as evaluateZoneRules } from "../dist-GFd8M6KO.mjs";
2
+ import { H as createEvent, I as errMsg, L as BaseAddon, O as pipelineExecutorCapability, P as supportedRuntimes$1, S as hfModelUrl, U as hydrateSchema, V as EventCategory, X as sleep, a as COCO_TO_MACRO, b as detectionPipelineCapability, f as YAMNET_TO_MACRO, i as COCO_80_LABELS, j as runtimeDevices$1, q as parseJsonUnknown, r as AUDIO_MACRO_LABELS, t as APPLE_SA_TO_MACRO, v as defaultDeviceFor$1, x as evaluateZoneRules } from "../dist-CgBGV988.mjs";
3
3
  import { t as pickNodePlatformArch } from "../node-topology-platform-BkR_k6WT.mjs";
4
4
  import { a as isModelDownloaded, i as ensureModel, n as deleteModelFromDisk } from "../model-download-service-C-IHWnXx-BPy6aoAx.mjs";
5
5
  import { FrameRingReaderCache } from "@camstack/shm-ring";
@@ -1175,7 +1175,7 @@ var ovFormat = (url, sizeMB) => {
1175
1175
  * `camstack-<id>-<precision>.xml`. Lets an operator scale the model to the node
1176
1176
  * (e.g. yolo26x-int8 on a 265K, yolo26n-int8 on an N100).
1177
1177
  */
1178
- var ovPrecisionVariant = (baseId, ovDir, baseName, precision, sizeMB) => ({
1178
+ var ovPrecisionVariant = (baseId, ovDir, baseName, precision, sizeMB, legacy = false) => ({
1179
1179
  id: `${baseId}-${precision}`,
1180
1180
  name: `${baseName} (${precision.toUpperCase()})`,
1181
1181
  description: `${baseName} — OpenVINO ${precision.toUpperCase()} variant for Intel iGPU/NPU; scale by hardware`,
@@ -1185,7 +1185,13 @@ var ovPrecisionVariant = (baseId, ovDir, baseName, precision, sizeMB) => ({
1185
1185
  },
1186
1186
  labels: [],
1187
1187
  preprocessMode: "letterbox",
1188
- formats: { openvino: ovFormat(hf(`${ovDir}/camstack-${baseId}-${precision}.xml`), sizeMB) }
1188
+ formats: { openvino: ovFormat(hf(`${ovDir}/camstack-${baseId}-${precision}.xml`), sizeMB) },
1189
+ ...legacy ? { legacy: true } : {},
1190
+ ...precision === "int8" ? { group: {
1191
+ family: baseId.slice(0, -1),
1192
+ tier: baseId.slice(-1),
1193
+ precision: "int8"
1194
+ } } : {}
1189
1195
  });
1190
1196
  var MLPACKAGE_FILES = [
1191
1197
  "Manifest.json",
@@ -1195,6 +1201,7 @@ var MLPACKAGE_FILES = [
1195
1201
  var OBJECT_DETECTION_MODELS = [
1196
1202
  {
1197
1203
  id: "yolov9t",
1204
+ legacy: true,
1198
1205
  name: "YOLOv9 Tiny",
1199
1206
  description: "YOLOv9 Tiny — ultra-lightweight next-gen detector",
1200
1207
  inputSize: {
@@ -1220,6 +1227,7 @@ var OBJECT_DETECTION_MODELS = [
1220
1227
  },
1221
1228
  {
1222
1229
  id: "yolov9s",
1230
+ legacy: true,
1223
1231
  name: "YOLOv9 Small",
1224
1232
  description: "YOLOv9 Small — improved efficiency over YOLOv8s",
1225
1233
  inputSize: {
@@ -1245,6 +1253,7 @@ var OBJECT_DETECTION_MODELS = [
1245
1253
  },
1246
1254
  {
1247
1255
  id: "yolov9c",
1256
+ legacy: true,
1248
1257
  name: "YOLOv9 C",
1249
1258
  description: "YOLOv9 C — high-accuracy compact model",
1250
1259
  inputSize: {
@@ -1270,6 +1279,10 @@ var OBJECT_DETECTION_MODELS = [
1270
1279
  },
1271
1280
  {
1272
1281
  id: "yolo26n",
1282
+ group: {
1283
+ family: "yolo26",
1284
+ tier: "n"
1285
+ },
1273
1286
  name: "YOLO26 Nano",
1274
1287
  description: "YOLO26 Nano — latest generation ultra-lightweight detector with attention mechanism",
1275
1288
  inputSize: {
@@ -1295,6 +1308,10 @@ var OBJECT_DETECTION_MODELS = [
1295
1308
  },
1296
1309
  {
1297
1310
  id: "yolo26s",
1311
+ group: {
1312
+ family: "yolo26",
1313
+ tier: "s"
1314
+ },
1298
1315
  name: "YOLO26 Small",
1299
1316
  description: "YOLO26 Small — balanced speed/accuracy with attention-based architecture",
1300
1317
  inputSize: {
@@ -1320,6 +1337,10 @@ var OBJECT_DETECTION_MODELS = [
1320
1337
  },
1321
1338
  {
1322
1339
  id: "yolo26m",
1340
+ group: {
1341
+ family: "yolo26",
1342
+ tier: "m"
1343
+ },
1323
1344
  name: "YOLO26 Medium",
1324
1345
  description: "YOLO26 Medium — high accuracy general-purpose detector",
1325
1346
  inputSize: {
@@ -1345,6 +1366,10 @@ var OBJECT_DETECTION_MODELS = [
1345
1366
  },
1346
1367
  {
1347
1368
  id: "yolo26l",
1369
+ group: {
1370
+ family: "yolo26",
1371
+ tier: "l"
1372
+ },
1348
1373
  name: "YOLO26 Large",
1349
1374
  description: "YOLO26 Large — maximum accuracy for demanding deployments",
1350
1375
  inputSize: {
@@ -1370,6 +1395,7 @@ var OBJECT_DETECTION_MODELS = [
1370
1395
  },
1371
1396
  {
1372
1397
  id: "yolo26x",
1398
+ legacy: true,
1373
1399
  name: "YOLO26 XLarge",
1374
1400
  description: "YOLO26 XLarge — highest accuracy, attention-based architecture",
1375
1401
  inputSize: {
@@ -1395,6 +1421,7 @@ var OBJECT_DETECTION_MODELS = [
1395
1421
  },
1396
1422
  {
1397
1423
  id: "scrypted-yolov9t-relu",
1424
+ legacy: true,
1398
1425
  name: "YOLOv9 Tiny ReLU",
1399
1426
  description: "YOLOv9t ReLU — optimized for int8 quantization",
1400
1427
  inputSize: {
@@ -1420,6 +1447,7 @@ var OBJECT_DETECTION_MODELS = [
1420
1447
  },
1421
1448
  {
1422
1449
  id: "scrypted-yolov9s-relu",
1450
+ legacy: true,
1423
1451
  name: "YOLOv9 Small ReLU",
1424
1452
  description: "YOLOv9s ReLU — improved efficiency, int8 ready",
1425
1453
  inputSize: {
@@ -1445,6 +1473,7 @@ var OBJECT_DETECTION_MODELS = [
1445
1473
  },
1446
1474
  {
1447
1475
  id: "scrypted-yolov9c-relu",
1476
+ legacy: true,
1448
1477
  name: "YOLOv9 C ReLU",
1449
1478
  description: "YOLOv9c ReLU — high-accuracy, int8 ready",
1450
1479
  inputSize: {
@@ -1470,6 +1499,7 @@ var OBJECT_DETECTION_MODELS = [
1470
1499
  },
1471
1500
  {
1472
1501
  id: "scrypted-yolov9m-relu",
1502
+ legacy: true,
1473
1503
  name: "YOLOv9 M ReLU",
1474
1504
  description: "YOLOv9m ReLU — medium, int8 ready",
1475
1505
  inputSize: {
@@ -1493,20 +1523,20 @@ var OBJECT_DETECTION_MODELS = [
1493
1523
  openvino: ovFormat(hf("objectDetection/scrypted-yolov9-relu/openvino/scrypted_yolov9m_relu.xml"), 38)
1494
1524
  }
1495
1525
  },
1496
- ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "fp16", 5),
1497
- ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "int8", 3),
1498
- ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "fp16", 15),
1499
- ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "int8", 8),
1500
- ovPrecisionVariant("yolo26n", "objectDetection/yolo26/openvino", "YOLO26 Nano", "fp16", 5),
1526
+ ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "fp16", 5, true),
1527
+ ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "int8", 3, true),
1528
+ ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "fp16", 15, true),
1529
+ ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "int8", 8, true),
1530
+ ovPrecisionVariant("yolo26n", "objectDetection/yolo26/openvino", "YOLO26 Nano", "fp16", 5, true),
1501
1531
  ovPrecisionVariant("yolo26n", "objectDetection/yolo26/openvino", "YOLO26 Nano", "int8", 3),
1502
- ovPrecisionVariant("yolo26s", "objectDetection/yolo26/openvino", "YOLO26 Small", "fp16", 19),
1532
+ ovPrecisionVariant("yolo26s", "objectDetection/yolo26/openvino", "YOLO26 Small", "fp16", 19, true),
1503
1533
  ovPrecisionVariant("yolo26s", "objectDetection/yolo26/openvino", "YOLO26 Small", "int8", 10),
1504
- ovPrecisionVariant("yolo26m", "objectDetection/yolo26/openvino", "YOLO26 Medium", "fp16", 41),
1534
+ ovPrecisionVariant("yolo26m", "objectDetection/yolo26/openvino", "YOLO26 Medium", "fp16", 41, true),
1505
1535
  ovPrecisionVariant("yolo26m", "objectDetection/yolo26/openvino", "YOLO26 Medium", "int8", 21),
1506
- ovPrecisionVariant("yolo26l", "objectDetection/yolo26/openvino", "YOLO26 Large", "fp16", 50),
1536
+ ovPrecisionVariant("yolo26l", "objectDetection/yolo26/openvino", "YOLO26 Large", "fp16", 50, true),
1507
1537
  ovPrecisionVariant("yolo26l", "objectDetection/yolo26/openvino", "YOLO26 Large", "int8", 25),
1508
- ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "fp16", 112),
1509
- ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "int8", 56)
1538
+ ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "fp16", 112, true),
1539
+ ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "int8", 56, true)
1510
1540
  ];
1511
1541
  var FACE_DETECTION_MODELS = [{
1512
1542
  id: "scrfd-2.5g",
@@ -2372,17 +2402,58 @@ function getStepDefinition(stepId) {
2372
2402
  }
2373
2403
  /**
2374
2404
  * Get the default model ID for a step given the current model format.
2375
- * Selects the smallest model that supports the format.
2376
- * Falls back to StepDefinition.defaultModelId if no format-specific model found.
2405
+ *
2406
+ * Resolution order:
2407
+ * 1. `def.defaultModelIdByFormat?.[format]` — the step's declared
2408
+ * per-format preference — if that model exists in `def.models` AND
2409
+ * has a build for `format`.
2410
+ * 2. `def.defaultModelId` — the step's plain declared default — if it
2411
+ * exists in `def.models` AND has a build for `format`.
2412
+ * 3. The smallest-by-size model among those with a `format` build
2413
+ * (legacy fallback, preserved for steps/formats with no declared
2414
+ * preference reachable).
2415
+ * 4. `def.defaultModelId` unchanged, when ZERO models have a `format`
2416
+ * build — an unloadable case flagged elsewhere, not resolved here.
2377
2417
  */
2378
2418
  function getDefaultModelForFormat(stepId, format) {
2379
2419
  const def = getStepDefinition(stepId);
2380
- const available = def.models.filter((m) => m.formats[format]);
2420
+ const hasFormatBuild = (modelId) => def.models.find((m) => m.id === modelId)?.formats[format] !== void 0;
2421
+ const declaredForFormat = def.defaultModelIdByFormat?.[format];
2422
+ if (declaredForFormat !== void 0 && hasFormatBuild(declaredForFormat)) return declaredForFormat;
2423
+ if (hasFormatBuild(def.defaultModelId)) return def.defaultModelId;
2424
+ const available = def.models.filter((m) => m.formats[format] && m.legacy !== true);
2381
2425
  if (available.length === 0) return def.defaultModelId;
2382
2426
  return [...available].toSorted((a, b) => {
2383
2427
  return (a.formats[format]?.sizeMB ?? Infinity) - (b.formats[format]?.sizeMB ?? Infinity);
2384
2428
  })[0].id;
2385
2429
  }
2430
+ /**
2431
+ * The single node-local authority for turning a CHOSEN model (or none) into a
2432
+ * concrete model that has a build for this node's engine `format`. Fuses the
2433
+ * two previously-duplicated decisions (orchestrator `pickDefaultModelId` +
2434
+ * provider `substituteIncompatibleModels`):
2435
+ *
2436
+ * - chosen === null → the addon's default model for `format`.
2437
+ * - chosen is a catalog model → keep it if it has a `format` build; else
2438
+ * the addon's default model for `format`
2439
+ * (a substitution — the caller surfaces it).
2440
+ * - chosen is NOT in the catalog (custom/unknown) → returned unchanged; the
2441
+ * operator owns a custom model's format compat.
2442
+ * - unknown stepId → chosen returned unchanged (never throws).
2443
+ */
2444
+ function resolveModelForFormat(stepId, chosenModelId, format) {
2445
+ let def;
2446
+ try {
2447
+ def = getStepDefinition(stepId);
2448
+ } catch {
2449
+ return chosenModelId ?? "";
2450
+ }
2451
+ if (chosenModelId === null) return getDefaultModelForFormat(stepId, format);
2452
+ const entry = def.models.find((m) => m.id === chosenModelId);
2453
+ if (!entry) return chosenModelId;
2454
+ if (entry.formats[format]) return chosenModelId;
2455
+ return getDefaultModelForFormat(stepId, format);
2456
+ }
2386
2457
  //#endregion
2387
2458
  //#region src/detection-pipeline/engine/engine-factory.ts
2388
2459
  var BACKEND_TO_POOL_RUNTIME = {
@@ -4201,6 +4272,147 @@ function mergeCustomModels(staticModels, customModels) {
4201
4272
  return merged;
4202
4273
  }
4203
4274
  //#endregion
4275
+ //#region src/detection-pipeline/step-resolution.ts
4276
+ /**
4277
+ * Walk `steps` (recursing into children) and return exactly the
4278
+ * substitutions in effect for `format` — one entry per step whose chosen
4279
+ * model differs from its resolved model. Pure and deterministic: the same
4280
+ * `(steps, format)` always yields the same result, with no entries left over
4281
+ * from a prior format. Callers that cache substitutions (e.g. for a status
4282
+ * surface) should REBUILD their cache from this result on every resolve
4283
+ * pass rather than merging into it, so a step that becomes compatible again
4284
+ * (chosen === resolved) is dropped instead of going stale.
4285
+ */
4286
+ function collectSubstitutions(steps, format) {
4287
+ const result = [];
4288
+ for (const step of steps) {
4289
+ const chosen = step.modelId;
4290
+ if (chosen != null) {
4291
+ const running = resolveModelForFormat(step.addonId, chosen, format);
4292
+ if (running !== chosen) result.push({
4293
+ addonId: step.addonId,
4294
+ chosen,
4295
+ running,
4296
+ format
4297
+ });
4298
+ }
4299
+ if (step.children?.length) result.push(...collectSubstitutions(step.children, format));
4300
+ }
4301
+ return result;
4302
+ }
4303
+ /**
4304
+ * Return one {@link ZeroBuildIssue} per step in `steps` whose catalog entry
4305
+ * (via `getStepDef`) has ZERO models with a build for `format`. `steps` is
4306
+ * expected to already be flattened + filtered to the set the caller cares
4307
+ * about (e.g. enabled video steps) — this does not recurse into children or
4308
+ * filter by `enabled`, unlike {@link collectSubstitutions}.
4309
+ *
4310
+ * `getStepDef` defaults to the real catalog lookup and is injectable so
4311
+ * tests can exercise the zero-build branch with a fabricated
4312
+ * `StepDefinition` — the real catalog currently has no step whose EVERY
4313
+ * model lacks a given format, so this branch is otherwise unreachable in a
4314
+ * black-box test. Unknown/custom addonIds (no registered `StepDefinition`)
4315
+ * are skipped, never thrown.
4316
+ */
4317
+ function collectZeroBuildIssues(steps, format, getStepDef = getStepDefinition) {
4318
+ const result = [];
4319
+ for (const step of steps) {
4320
+ let def;
4321
+ try {
4322
+ def = getStepDef(step.addonId);
4323
+ } catch {
4324
+ continue;
4325
+ }
4326
+ if (!def.models.some((m) => m.formats[format])) result.push({
4327
+ addonId: step.addonId,
4328
+ format
4329
+ });
4330
+ }
4331
+ return result;
4332
+ }
4333
+ /**
4334
+ * Walk `steps` (recursing into children, mirroring {@link collectSubstitutions}'
4335
+ * recursion — every step in the tree is checked regardless of `enabled`) and
4336
+ * return one {@link UnknownAddonIssue} per addonId whose `getStepDef` lookup
4337
+ * throws. Pure, deterministic, never throws itself.
4338
+ *
4339
+ * `getStepDef` defaults to the real catalog lookup and is injectable so
4340
+ * tests can exercise the unknown-addon branch with a fabricated resolver
4341
+ * without `vi.mock`.
4342
+ */
4343
+ function collectUnknownAddonIssues(steps, getStepDef = getStepDefinition) {
4344
+ const result = [];
4345
+ for (const step of steps) {
4346
+ try {
4347
+ getStepDef(step.addonId);
4348
+ } catch {
4349
+ result.push({ addonId: step.addonId });
4350
+ }
4351
+ if (step.children?.length) result.push(...collectUnknownAddonIssues(step.children, getStepDef));
4352
+ }
4353
+ return result;
4354
+ }
4355
+ /**
4356
+ * Enrich `PipelineStepInput[]` (the wire shape a camera pipeline sends) into
4357
+ * `PipelineDefaultStep[]` (the catalog-enriched shape the executor consumes),
4358
+ * resolving each step's model for `format` via `resolveModelForFormat`.
4359
+ *
4360
+ * Pure and side-effect-free — never throws. This is the seam behind the
4361
+ * LIVE per-camera dispatch path (`DetectionPipelineProvider.
4362
+ * inputStepsToPipelineSteps`), which runs once per decoded frame (up to
4363
+ * detectionFps × N cameras/sec): a bad step config referencing an unknown
4364
+ * `addonId` must never become a per-frame throw. Unknown addons are
4365
+ * dropped from the returned tree and reported in
4366
+ * `diagnostics.unknownAddonIds`; model substitutions are reported in
4367
+ * `diagnostics.substitutions` instead of happening silently. The caller
4368
+ * (the provider) owns logging + dedup for both.
4369
+ */
4370
+ function resolveInputSteps(steps, format) {
4371
+ const resolvedSteps = [];
4372
+ const unknownAddonIds = [];
4373
+ const substitutions = [];
4374
+ for (const s of steps) {
4375
+ let def;
4376
+ try {
4377
+ def = getStepDefinition(s.addonId);
4378
+ } catch {
4379
+ unknownAddonIds.push(s.addonId);
4380
+ continue;
4381
+ }
4382
+ const chosenModelId = s.modelId ?? null;
4383
+ const runningModelId = resolveModelForFormat(s.addonId, chosenModelId, format);
4384
+ if (chosenModelId !== null && runningModelId !== chosenModelId) substitutions.push({
4385
+ addonId: s.addonId,
4386
+ chosen: chosenModelId,
4387
+ running: runningModelId,
4388
+ format
4389
+ });
4390
+ const childResult = s.children ? resolveInputSteps(s.children, format) : null;
4391
+ if (childResult) {
4392
+ unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
4393
+ substitutions.push(...childResult.diagnostics.substitutions);
4394
+ }
4395
+ resolvedSteps.push({
4396
+ addonId: s.addonId,
4397
+ addonName: def.name,
4398
+ slot: def.slot,
4399
+ inputClasses: def.inputClasses ?? [],
4400
+ outputClasses: def.outputClasses,
4401
+ enabled: s.enabled,
4402
+ modelId: runningModelId,
4403
+ children: childResult ? childResult.steps : [],
4404
+ ...s.settings ? { settings: { ...s.settings } } : {}
4405
+ });
4406
+ }
4407
+ return {
4408
+ steps: resolvedSteps,
4409
+ diagnostics: {
4410
+ unknownAddonIds,
4411
+ substitutions
4412
+ }
4413
+ };
4414
+ }
4415
+ //#endregion
4204
4416
  //#region src/detection-pipeline/provider.ts
4205
4417
  /**
4206
4418
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4497,6 +4709,15 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4497
4709
  */
4498
4710
  lastStepsSignature = null;
4499
4711
  /**
4712
+ * Dedup guard for the live per-camera dispatch path
4713
+ * (`inputStepsToPipelineSteps`) — an unknown-addon skip or a runtime
4714
+ * model substitution logs ONCE per distinct signature instead of once
4715
+ * per decoded frame (that method runs up to detectionFps × N cameras
4716
+ * times/sec). Never cleared — it's a log-noise guard, not runtime
4717
+ * state, and the key space (addonId/model combos) is small and stable.
4718
+ */
4719
+ loggedLiveDispatchIssues = /* @__PURE__ */ new Set();
4720
+ /**
4500
4721
  * CB5 shm passthrough — the process-local, LONG-LIVED reader cache for
4501
4722
  * `runPipeline({ frameHandle })`. Opens each named shm segment ONCE and
4502
4723
  * reuses the mapping for every later handle on the same ring (never
@@ -4525,6 +4746,17 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4525
4746
  */
4526
4747
  provisioner = null;
4527
4748
  /**
4749
+ * Gate A (config-correctness gate at engine change). Human-readable config
4750
+ * issues — model substitutions and zero-build steps — for the CURRENT
4751
+ * node-default tree against `this.currentEngine.format`. Recomputed
4752
+ * EAGERLY by {@link recomputeConfigIssues} whenever the engine actually
4753
+ * changes (see `onEngineSelectionChanged`), rather than relying on a
4754
+ * `getGlobalSteps()` call that may never happen on a camera-only node.
4755
+ * Merged into {@link getEngineProvisioning}'s snapshot; never consulted by
4756
+ * `assertEngineReady` — this gate SURFACES, it does not block inference.
4757
+ */
4758
+ configIssues = [];
4759
+ /**
4528
4760
  * True when `init()` found no persisted engine choice (first boot).
4529
4761
  * `setApi()` reads this flag and auto-picks the best supported runtime
4530
4762
  * using probe data (hardware + bestScore hint), persists the selection,
@@ -4771,7 +5003,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4771
5003
  const stored = await this.loadEngine();
4772
5004
  if (stored) this.currentEngine = stored;
4773
5005
  else await this.resolveAutoEngine();
4774
- if ((prev.runtime !== this.currentEngine.runtime || prev.backend !== this.currentEngine.backend || prev.format !== this.currentEngine.format || (prev.device ?? "") !== (this.currentEngine.device ?? "")) && this.engineFactory) {
5006
+ const engineChanged = prev.runtime !== this.currentEngine.runtime || prev.backend !== this.currentEngine.backend || prev.format !== this.currentEngine.format || (prev.device ?? "") !== (this.currentEngine.device ?? "");
5007
+ if (engineChanged && this.engineFactory) {
4775
5008
  this.log.info("engine selection changed — rebuilding pool in place", { meta: {
4776
5009
  from: `${prev.backend}/${prev.device ?? "default"}`,
4777
5010
  to: `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`
@@ -4783,18 +5016,56 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4783
5016
  this.executor = null;
4784
5017
  }
4785
5018
  this.startProvisioningForCurrentEngine();
5019
+ if (engineChanged) await this.recomputeConfigIssues();
5020
+ }
5021
+ /**
5022
+ * Gate A (config-correctness gate at engine change) — eagerly re-resolve
5023
+ * the node-default step tree for `this.currentEngine.format` and compute
5024
+ * human-readable config issues from the result:
5025
+ * - model substitutions (`getActiveModelSubstitutions()`, populated as a
5026
+ * side effect of the `getGlobalSteps()` re-resolve below) — a step's
5027
+ * chosen model lacks a build for this format, so the format default
5028
+ * runs instead (degraded but loadable).
5029
+ * - zero-build steps (`collectZeroBuildIssues`) — a step whose FULL
5030
+ * catalog has no model at all with a build for this format (not even
5031
+ * the format default is loadable).
5032
+ *
5033
+ * Never throws — this is an additive diagnostic surface, not an
5034
+ * enforcement point (`assertEngineReady` still owns readiness). Any
5035
+ * failure clears `configIssues` and logs a warning rather than
5036
+ * propagating out of the engine-change path.
5037
+ */
5038
+ async recomputeConfigIssues() {
5039
+ this.configIssues = [];
5040
+ try {
5041
+ const steps = await this.getGlobalSteps();
5042
+ if (!steps) return;
5043
+ const format = this.currentEngine.format;
5044
+ const substitutionIssues = this.getActiveModelSubstitutions().map((s) => `${s.addonId}: chose ${s.chosen}, running ${s.running} (${s.format})`);
5045
+ const zeroBuildIssues = collectZeroBuildIssues(flattenSteps(steps), format).map((i) => `${i.addonId}: no model has a ${i.format} build`);
5046
+ this.configIssues = [...substitutionIssues, ...zeroBuildIssues];
5047
+ } catch (err) {
5048
+ this.configIssues = [];
5049
+ this.log.warn("engine-change config gate failed — no config issues surfaced", { meta: { error: errMsg(err) } });
5050
+ }
4786
5051
  }
4787
5052
  /**
4788
5053
  * Per-node engine-provisioning snapshot. The cap routes `{ nodeId }` for
4789
5054
  * provider resolution only (the router strips it); each node returns its
4790
- * own local machine state. Cold (pre-`setApi`) provider → `idle`.
5055
+ * own local machine state. Cold (pre-`setApi`) provider → `idle`. Merges
5056
+ * in Gate A's `configIssues` (see {@link recomputeConfigIssues}) when
5057
+ * non-empty — additive, never overrides the provisioner's own fields.
4791
5058
  */
4792
5059
  getEngineProvisioning() {
4793
- return this.provisioner?.state ?? {
5060
+ const base = this.provisioner?.state ?? {
4794
5061
  runtimeId: null,
4795
5062
  device: null,
4796
5063
  state: "idle"
4797
5064
  };
5065
+ return this.configIssues.length ? {
5066
+ ...base,
5067
+ configIssues: [...this.configIssues]
5068
+ } : base;
4798
5069
  }
4799
5070
  /**
4800
5071
  * Inference gate. Rejects with the current phase/error unless the engine
@@ -4993,43 +5264,45 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4993
5264
  return buildDefaultStepTree(engine.format);
4994
5265
  }
4995
5266
  /**
4996
- * Substitute, AT RUNTIME ONLY (never persisted), any step whose configured
4997
- * model lacks a build for that step's active engine format. A GLOBAL pipeline
4998
- * config can pin a model that's valid on the cluster's Intel nodes (e.g. the
4999
- * OpenVINO-only `yolov9t-int8`) but impossible on a CoreML/ONNX node — without
5000
- * this the node crash-loops `Model "X" has no <format> format`. The operator's
5001
- * choice stays in the persisted config; this node just runs the smallest
5002
- * catalog model that DOES have its format. Mirrors the per-node engine
5003
- * fallback in `loadEngine`. Catalog models only a custom model that lacks
5004
- * the format is left as-is (operator's responsibility).
5267
+ * Resolve every step's model for this node's engine format via the shared
5268
+ * `resolveModelForFormat`, and surface any substitution. `step.engine?.format`
5269
+ * is gone `this.currentEngine.format` is the only format source (engine is
5270
+ * node-local).
5271
+ *
5272
+ * Rebuilds `modelSubstitutions` from scratch (via {@link collectSubstitutions})
5273
+ * on every call, from the FULL tree being resolved, so the map always
5274
+ * reflects EXACTLY the substitutions in effect for the current tree+format
5275
+ * a step whose chosen model becomes compatible again (e.g. after the
5276
+ * operator switches the engine to a format it supports) is cleared here
5277
+ * instead of leaking a stale "chose X → running Y" entry.
5005
5278
  */
5006
- substituteIncompatibleModels(steps) {
5007
- const fix = (step) => {
5008
- const format = step.engine?.format ?? this.currentEngine.format;
5009
- let modelId = step.modelId;
5010
- try {
5011
- const entry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
5012
- if (entry && !entry.formats[format]) {
5013
- const fallback = getDefaultModelForFormat(step.addonId, format);
5014
- if (fallback !== step.modelId) {
5015
- this.log.info("Step model lacks engine format — substituting format default (runtime)", { meta: {
5016
- step: step.addonId,
5017
- configured: step.modelId,
5018
- substitute: fallback,
5019
- format
5020
- } });
5021
- modelId = fallback;
5022
- }
5023
- }
5024
- } catch {}
5025
- const children = step.children?.length ? step.children.map(fix) : step.children;
5026
- return modelId === step.modelId && children === step.children ? step : {
5279
+ resolveStepModels(steps) {
5280
+ const format = this.currentEngine.format;
5281
+ const substitutions = collectSubstitutions(steps, format);
5282
+ this.modelSubstitutions = new Map(substitutions.map((s) => [s.addonId, s]));
5283
+ for (const s of substitutions) this.log.info("Step model lacks engine format — running format default (node-local)", { meta: {
5284
+ step: s.addonId,
5285
+ chosen: s.chosen,
5286
+ running: s.running,
5287
+ format: s.format
5288
+ } });
5289
+ return this.resolveStepModelTree(steps, format);
5290
+ }
5291
+ /** Pure recursive resolver — builds the resolved step tree without touching `modelSubstitutions`. */
5292
+ resolveStepModelTree(steps, format) {
5293
+ return steps.map((step) => {
5294
+ const resolved = resolveModelForFormat(step.addonId, step.modelId ?? null, format);
5295
+ const children = step.children?.length ? this.resolveStepModelTree([...step.children], format) : step.children;
5296
+ return resolved === step.modelId && children === step.children ? step : {
5027
5297
  ...step,
5028
- modelId,
5298
+ modelId: resolved,
5029
5299
  ...children ? { children } : {}
5030
5300
  };
5031
- };
5032
- return steps.map(fix);
5301
+ });
5302
+ }
5303
+ modelSubstitutions = /* @__PURE__ */ new Map();
5304
+ getActiveModelSubstitutions() {
5305
+ return [...this.modelSubstitutions.values()];
5033
5306
  }
5034
5307
  async getGlobalSteps() {
5035
5308
  if (this.currentSteps) return this.currentSteps;
@@ -5037,7 +5310,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5037
5310
  if (!raw) {
5038
5311
  const defaults = buildDefaultStepTree(this.currentEngine.format);
5039
5312
  if (defaults.length === 0) return null;
5040
- this.currentSteps = this.substituteIncompatibleModels(defaults);
5313
+ this.currentSteps = this.resolveStepModels(defaults);
5041
5314
  this.writeStore({ [KEY_STEPS]: JSON.stringify(defaults) });
5042
5315
  this.log.info("Bootstrapped default pipeline — object-detection + face + plate recognition enabled by default", { meta: { rootSteps: defaults.length } });
5043
5316
  return this.currentSteps;
@@ -5050,15 +5323,6 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5050
5323
  const audioDef = getStepDefinition("audio-classifier");
5051
5324
  if (audioDef.models.some((m) => m.formats[format])) {
5052
5325
  const modelId = getDefaultModelForFormat("audio-classifier", format);
5053
- const audioEngine = modelId === "apple-soundanalysis" ? {
5054
- runtime: "python",
5055
- backend: "coreml",
5056
- format: "coreml"
5057
- } : {
5058
- runtime: "python",
5059
- backend: "cpu",
5060
- format: "onnx"
5061
- };
5062
5326
  const audioStep = {
5063
5327
  addonId: audioDef.id,
5064
5328
  addonName: audioDef.name,
@@ -5067,15 +5331,14 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5067
5331
  outputClasses: audioDef.outputClasses,
5068
5332
  enabled: true,
5069
5333
  modelId,
5070
- children: [],
5071
- engine: audioEngine
5334
+ children: []
5072
5335
  };
5073
5336
  steps = [...steps, audioStep];
5074
5337
  this.writeStore({ [KEY_STEPS]: JSON.stringify(steps) });
5075
5338
  this.log.info("Migration: added audio-classifier step to persisted pipeline config");
5076
5339
  }
5077
5340
  }
5078
- this.currentSteps = this.substituteIncompatibleModels(steps);
5341
+ this.currentSteps = this.resolveStepModels(steps);
5079
5342
  return this.currentSteps;
5080
5343
  } catch {
5081
5344
  throw new Error(`Failed to parse persisted pipeline steps: corrupt data in key "${KEY_STEPS}"`);
@@ -5085,6 +5348,128 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5085
5348
  if (!this.currentSteps) return null;
5086
5349
  return stepsToPipelineConfig(this.currentSteps, this.currentEngine);
5087
5350
  }
5351
+ /**
5352
+ * Task G5 (Gate B) — pre-init config-correctness gate. PURE COMPUTE
5353
+ * against `this.currentEngine.format`: resolves `steps` the same way the
5354
+ * runtime dispatch path (`resolveInputSteps`) would and reports what
5355
+ * WOULD happen, without touching `this.modelSubstitutions` (that node-
5356
+ * global map stays the exclusive property of `resolveStepModels`, the
5357
+ * node-default tree resolver — writing it here would race two
5358
+ * independent callers over the same field). Called by the orchestrator
5359
+ * at attach time, node-pinned to the target node.
5360
+ *
5361
+ * Composes three pure helpers from `step-resolution.ts`, ALL scoped to
5362
+ * ENABLED steps only (a disabled step's subtree — and any unknown addonId
5363
+ * or incompatible model inside it — contributes NO issue and NO
5364
+ * substitution; it will never execute, so flagging it is false-positive
5365
+ * noise):
5366
+ * - `collectSubstitutions` → `substitutions` (informational, never
5367
+ * affects `ok`).
5368
+ * - `collectUnknownAddonIssues` → `unknown-addon` HARD issues.
5369
+ * - `collectZeroBuildIssues` (over the enabled, non-audio-classifier
5370
+ * subset — mirrors Gate A's `flattenSteps`/`flattenEnabledVideoSteps`
5371
+ * filter, since audio backends are validated by audio-analyzer, not
5372
+ * against this node's VIDEO engine format) → `no-format-build` HARD
5373
+ * issues.
5374
+ *
5375
+ * `collectSubstitutions` and `collectUnknownAddonIssues` recurse into
5376
+ * `children` themselves, so the enabled-scoping is applied ONCE up front
5377
+ * via {@link pruneToEnabledSteps} (which drops a disabled step's whole
5378
+ * subtree) rather than re-implemented inside each collector.
5379
+ *
5380
+ * Never throws — any unexpected failure degrades to a clean `ok: true`
5381
+ * result with a warning logged, same posture as `recomputeConfigIssues`.
5382
+ */
5383
+ async validatePipeline(input) {
5384
+ const format = this.currentEngine.format;
5385
+ try {
5386
+ const steps = input.steps;
5387
+ const enabledSteps = this.pruneToEnabledSteps(steps);
5388
+ const substitutions = collectSubstitutions(enabledSteps, format);
5389
+ const unknownAddonIssues = collectUnknownAddonIssues(enabledSteps);
5390
+ const zeroBuildIssues = collectZeroBuildIssues(this.flattenEnabledVideoStepInputs(steps), format);
5391
+ const issues = [...unknownAddonIssues.map((u) => ({
5392
+ addonId: u.addonId,
5393
+ kind: "unknown-addon",
5394
+ detail: `${u.addonId}: no registered step definition`
5395
+ })), ...zeroBuildIssues.map((z) => ({
5396
+ addonId: z.addonId,
5397
+ kind: "no-format-build",
5398
+ detail: `${z.addonId}: no model has a ${z.format} build`
5399
+ }))];
5400
+ return {
5401
+ ok: issues.length === 0,
5402
+ issues,
5403
+ substitutions,
5404
+ format
5405
+ };
5406
+ } catch (err) {
5407
+ this.log.warn("validatePipeline failed — reporting a clean result", { meta: { error: errMsg(err) } });
5408
+ return {
5409
+ ok: true,
5410
+ issues: [],
5411
+ substitutions: [],
5412
+ format
5413
+ };
5414
+ }
5415
+ }
5416
+ /**
5417
+ * Prune `steps` to ENABLED steps only, recursing into ENABLED children —
5418
+ * a disabled step's whole subtree is dropped, never partially kept. This
5419
+ * is the enabled-only tree shape `collectSubstitutions` and
5420
+ * `collectUnknownAddonIssues` need in {@link validatePipeline}: both
5421
+ * recurse into `children` themselves (unlike `flattenEnabledVideoStepInputs`,
5422
+ * which flattens AND excludes `audio-classifier`), so pruning once up
5423
+ * front — without touching the audio/video split — is enough to make
5424
+ * Gate B's three checks agree on scope. Pure; returns the original array
5425
+ * reference when nothing changed.
5426
+ */
5427
+ pruneToEnabledSteps(steps) {
5428
+ const result = [];
5429
+ let changed = false;
5430
+ for (const step of steps) {
5431
+ if (!step.enabled) {
5432
+ changed = true;
5433
+ continue;
5434
+ }
5435
+ const prunedChildren = step.children?.length ? this.pruneToEnabledSteps(step.children) : step.children;
5436
+ if (prunedChildren !== step.children) {
5437
+ changed = true;
5438
+ result.push({
5439
+ ...step,
5440
+ children: prunedChildren
5441
+ });
5442
+ } else result.push(step);
5443
+ }
5444
+ return changed ? result : steps;
5445
+ }
5446
+ /**
5447
+ * Flatten `steps` (recursing into ENABLED children only — a disabled
5448
+ * step's subtree is skipped entirely, mirroring
5449
+ * `flattenEnabledVideoSteps`) into `{addonId}` entries, excluding
5450
+ * `audio-classifier` steps: Gate B validates the VIDEO engine format
5451
+ * (`this.currentEngine.format`) only — audio backends resolve their own
5452
+ * format independently and are validated by the audio-analyzer addon.
5453
+ * An addonId whose `StepDefinition` lookup throws is skipped here (it is
5454
+ * already reported separately by `collectUnknownAddonIssues`).
5455
+ */
5456
+ flattenEnabledVideoStepInputs(steps) {
5457
+ const result = [];
5458
+ const walk = (nodes) => {
5459
+ for (const step of nodes) {
5460
+ if (!step.enabled) continue;
5461
+ try {
5462
+ if (getStepDefinition(step.addonId).slot === "audio-classifier") continue;
5463
+ } catch {
5464
+ continue;
5465
+ }
5466
+ result.push({ addonId: step.addonId });
5467
+ if (step.children?.length) walk(step.children);
5468
+ }
5469
+ };
5470
+ walk(steps);
5471
+ return result;
5472
+ }
5088
5473
  async getSelectedEngine() {
5089
5474
  return this.currentEngine;
5090
5475
  }
@@ -5097,7 +5482,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5097
5482
  const addons = ALL_PIPELINE_STEPS.map((s) => ({
5098
5483
  id: s.definition.id,
5099
5484
  name: s.definition.name,
5100
- models: s.definition.models.map((m) => {
5485
+ models: s.definition.models.filter((m) => m.legacy !== true).map((m) => {
5101
5486
  const formats = {};
5102
5487
  for (const [formatKey, entry] of Object.entries(m.formats)) {
5103
5488
  if (!entry) continue;
@@ -5411,7 +5796,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5411
5796
  emit(`Image: ${imageWidth}×${imageHeight}`);
5412
5797
  }
5413
5798
  decodeMs = performance.now() - decodeT0;
5414
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps);
5799
+ const resolveFormat = input.engine?.format ?? this.currentEngine.format;
5800
+ const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat);
5415
5801
  const stepsSignature = JSON.stringify(benchmarkSteps.map((s) => ({
5416
5802
  id: s.addonId,
5417
5803
  settings: s.settings ?? {}
@@ -5700,22 +6086,46 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5700
6086
  this.log.warn("Override cache dispose failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
5701
6087
  }
5702
6088
  }
5703
- /** Convert PipelineStepInput[] → PipelineDefaultStep[] by enriching from StepDefinitions */
5704
- inputStepsToPipelineSteps(steps) {
5705
- return steps.map((s) => {
5706
- const def = getStepDefinition(s.addonId);
5707
- return {
5708
- addonId: s.addonId,
5709
- addonName: def.name,
5710
- slot: def.slot,
5711
- inputClasses: def.inputClasses ?? [],
5712
- outputClasses: def.outputClasses,
5713
- enabled: s.enabled,
5714
- modelId: s.modelId,
5715
- children: s.children ? this.inputStepsToPipelineSteps(s.children) : [],
5716
- ...s.settings ? { settings: { ...s.settings } } : {}
5717
- };
5718
- });
6089
+ /**
6090
+ * Convert PipelineStepInput[] → PipelineDefaultStep[] by enriching from
6091
+ * StepDefinitions. This is the LIVE per-camera dispatch path — it runs
6092
+ * once per decoded frame (up to detectionFps × N cameras/sec) — so it
6093
+ * must NEVER throw. A thin adapter over the pure {@link resolveInputSteps}
6094
+ * seam: an unknown `addonId` (a bad step config sent by a camera
6095
+ * pipeline) is dropped from the tree and logged once (deduped, not
6096
+ * per-frame) at warn level instead of propagating as a per-frame error
6097
+ * loop. A runtime model substitution (the step's explicit model lacks a
6098
+ * build for this node's engine format, so the format default runs
6099
+ * instead) is logged once (deduped) at info level so an operator can see
6100
+ * a camera×node model swap instead of it happening silently. Does NOT
6101
+ * write `this.modelSubstitutions` that map is owned exclusively by
6102
+ * `resolveStepModels` (see its doc comment); a second writer here would
6103
+ * corrupt it.
6104
+ *
6105
+ * `format` defaults to `this.currentEngine.format` (the LIVE per-camera
6106
+ * dispatch path never passes it explicitly). Benchmark call sites with a
6107
+ * per-run engine override (`input.engine`) MUST pass the override's format
6108
+ * explicitly — `this.currentEngine` only gets swapped to the override
6109
+ * LATER in `runPipeline`/`runPipelineBatch`, so relying on the default here
6110
+ * would resolve models against the node's persisted format instead of the
6111
+ * format actually being benchmarked.
6112
+ */
6113
+ inputStepsToPipelineSteps(steps, format = this.currentEngine.format) {
6114
+ const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format);
6115
+ for (const addonId of diagnostics.unknownAddonIds) this.logLiveDispatchIssueOnce(`unknown:${addonId}`, () => this.log.warn("Live pipeline step references an unknown addon — dropping step", { meta: { addonId } }));
6116
+ for (const sub of diagnostics.substitutions) this.logLiveDispatchIssueOnce(`${sub.addonId}|${sub.chosen}|${sub.running}|${sub.format}`, () => this.log.info("Live pipeline step model substituted for engine format (node-local)", { meta: {
6117
+ step: sub.addonId,
6118
+ chosen: sub.chosen,
6119
+ running: sub.running,
6120
+ format: sub.format
6121
+ } }));
6122
+ return resolvedSteps;
6123
+ }
6124
+ /** Run `fn()` at most once per `key` for the lifetime of this provider instance. */
6125
+ logLiveDispatchIssueOnce(key, fn) {
6126
+ if (this.loggedLiveDispatchIssues.has(key)) return;
6127
+ this.loggedLiveDispatchIssues.add(key);
6128
+ fn();
5719
6129
  }
5720
6130
  /**
5721
6131
  * Single-flight gate around `ensureModelsForSteps`. Concurrent
@@ -5921,7 +6331,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5921
6331
  }
5922
6332
  async runPipelineBatchImpl(input) {
5923
6333
  if (input.frames.length === 0) return { results: [] };
5924
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps);
6334
+ const resolveFormat = input.engine?.format ?? this.currentEngine.format;
6335
+ const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat);
5925
6336
  const enabledSteps = flattenEnabledVideoSteps(benchmarkSteps);
5926
6337
  if (enabledSteps.length === 0) throw new Error("runPipelineBatch: no enabled steps");
5927
6338
  const rootStep = enabledSteps[0];
@@ -6613,7 +7024,7 @@ function buildSchemaSlots(format, modelsDir, customByStep) {
6613
7024
  const slotMap = /* @__PURE__ */ new Map();
6614
7025
  for (const pipelineStep of ALL_PIPELINE_STEPS) {
6615
7026
  const step = pipelineStep.definition;
6616
- const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.formats[format]);
7027
+ const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.formats[format] && m.legacy !== true);
6617
7028
  if (availableModels.length === 0) continue;
6618
7029
  const slot = step.slot;
6619
7030
  if (!slotMap.has(slot)) slotMap.set(slot, []);
@@ -6631,7 +7042,9 @@ function buildSchemaSlots(format, modelsDir, customByStep) {
6631
7042
  formats: Object.fromEntries(Object.entries(m.formats).map(([f, entry]) => [f, {
6632
7043
  downloaded: isModelDownloaded(modelsDir, m, f),
6633
7044
  sizeMB: entry.sizeMB
6634
- }]))
7045
+ }])),
7046
+ ...m.group ? { group: m.group } : {},
7047
+ ...m.legacy !== void 0 ? { legacy: m.legacy } : {}
6635
7048
  })),
6636
7049
  defaultModelId: step.defaultModelId,
6637
7050
  ...step.defaultModelIdByFormat ? { defaultModelIdByFormat: { ...step.defaultModelIdByFormat } } : {},
@@ -6720,23 +7133,10 @@ function buildDefaultStepTree(format) {
6720
7133
  makeStep("vehicle-classifier", [], { enabled: false }),
6721
7134
  makeStep("segmentation", [], { enabled: false })
6722
7135
  ].filter((s) => s !== null));
6723
- const audioEngine = getDefaultModelForFormat("audio-classifier", format) === "apple-soundanalysis" ? {
6724
- runtime: "python",
6725
- backend: "coreml",
6726
- format: "coreml"
6727
- } : {
6728
- runtime: "python",
6729
- backend: "cpu",
6730
- format: "onnx"
6731
- };
6732
7136
  const audioStep = makeStep("audio-classifier", []);
6733
- const audioClassifier = audioStep ? {
6734
- ...audioStep,
6735
- engine: audioEngine
6736
- } : null;
6737
7137
  const steps = [];
6738
7138
  if (root) steps.push(root);
6739
- if (audioClassifier) steps.push(audioClassifier);
7139
+ if (audioStep) steps.push(audioStep);
6740
7140
  return steps;
6741
7141
  }
6742
7142
  function stepsToPipelineConfig(steps, engine) {
@@ -6756,7 +7156,7 @@ function stepsToPipelineConfig(steps, engine) {
6756
7156
  video: videoSteps.map(toNode),
6757
7157
  ...audioStep ? {
6758
7158
  audio: toNode(audioStep),
6759
- audioEngine: audioStep.engine ?? engine
7159
+ audioEngine: engine
6760
7160
  } : {}
6761
7161
  };
6762
7162
  }
@@ -6888,7 +7288,8 @@ var DEFAULT_CONFIG = {
6888
7288
  engineBackend: "auto",
6889
7289
  engineDevice: "cpu",
6890
7290
  probedBestEngine: "",
6891
- activeEngine: ""
7291
+ activeEngine: "",
7292
+ activeModelSubstitutions: ""
6892
7293
  };
6893
7294
  /** Derive the model-format from a backend value. Called by the provider. */
6894
7295
  function backendToFormat(backend) {
@@ -7009,6 +7410,18 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7009
7410
  readonlyField: true,
7010
7411
  default: ""
7011
7412
  }),
7413
+ this.field({
7414
+ type: "text",
7415
+ key: "activeModelSubstitutions",
7416
+ label: "Model substitutions",
7417
+ description: "Node-local model substitutions currently in effect: a step's chosen model has no build for this node's engine format, so a compatible default is running instead (format: \"step: chosen→running\"). Hidden when every step matches.",
7418
+ readonlyField: true,
7419
+ default: "",
7420
+ showWhen: {
7421
+ field: "activeModelSubstitutions",
7422
+ notEquals: ""
7423
+ }
7424
+ }),
7012
7425
  this.field({
7013
7426
  type: "select",
7014
7427
  key: "engineBackend",
@@ -7178,12 +7591,15 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7178
7591
  const device = deviceOptions.find((d) => d.value === storedDevice)?.value ?? defaultDeviceFor(backend);
7179
7592
  const prov = targetNode === this.localNodeId() ? this.provider?.getEngineProvisioning() : void 0;
7180
7593
  const activeEngine = prov && prov.runtimeId ? `${prov.runtimeId}/${prov.device ?? "default"} (${prov.state})` : "";
7594
+ const substitutions = this.provider?.getActiveModelSubstitutions() ?? [];
7595
+ const activeModelSubstitutions = substitutions.length === 0 ? "" : substitutions.map((s) => `${s.addonId}: ${s.chosen}→${s.running}`).join(", ");
7181
7596
  const displayBackend = isAuto ? "auto" : backend;
7182
7597
  const raw = {
7183
7598
  ...merged,
7184
7599
  engineBackend: displayBackend,
7185
7600
  engineDevice: device,
7186
- activeEngine
7601
+ activeEngine,
7602
+ activeModelSubstitutions
7187
7603
  };
7188
7604
  const schema = this.globalSettingsSchema();
7189
7605
  if (!schema) return { sections: [] };
@@ -7447,7 +7863,7 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends BaseAddon {
7447
7863
  }).catch(() => {});
7448
7864
  }
7449
7865
  getModelCatalog() {
7450
- return ALL_STEPS.flatMap((s) => [...s.models]);
7866
+ return ALL_STEPS.flatMap((s) => s.models.filter((m) => m.legacy !== true));
7451
7867
  }
7452
7868
  /**
7453
7869
  * Proactively install the OpenVINO Python package when Intel hardware