@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
@@ -2,19 +2,20 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-DxM2DSns.js");
6
- const require_dist = require("../dist-NvwN60Fq.js");
5
+ const require_chunk = require("../chunk-D6vf50IK.js");
6
+ const require_dist = require("../dist-CySHUkXY.js");
7
7
  const require_node_topology_platform = require("../node-topology-platform-CFZ7F4xW.js");
8
+ const require_model_download_service_C_IHWnXx = require("../model-download-service-C-IHWnXx-BLXzxpRB.js");
8
9
  let _camstack_shm_ring = require("@camstack/shm-ring");
9
10
  let node_child_process = require("node:child_process");
10
11
  let node_fs = require("node:fs");
11
- node_fs = require_model_download_service_C_IHWnXx.__toESM(node_fs);
12
+ node_fs = require_chunk.__toESM(node_fs);
12
13
  let node_path = require("node:path");
13
- node_path = require_model_download_service_C_IHWnXx.__toESM(node_path);
14
+ node_path = require_chunk.__toESM(node_path);
14
15
  let node_os = require("node:os");
15
- node_os = require_model_download_service_C_IHWnXx.__toESM(node_os);
16
+ node_os = require_chunk.__toESM(node_os);
16
17
  let sharp = require("sharp");
17
- sharp = require_model_download_service_C_IHWnXx.__toESM(sharp);
18
+ sharp = require_chunk.__toESM(sharp);
18
19
  //#region src/detection-pipeline/runtimes.ts
19
20
  var KNOWN_PLATFORMS = [
20
21
  "darwin",
@@ -1182,7 +1183,7 @@ var ovFormat = (url, sizeMB) => {
1182
1183
  * `camstack-<id>-<precision>.xml`. Lets an operator scale the model to the node
1183
1184
  * (e.g. yolo26x-int8 on a 265K, yolo26n-int8 on an N100).
1184
1185
  */
1185
- var ovPrecisionVariant = (baseId, ovDir, baseName, precision, sizeMB) => ({
1186
+ var ovPrecisionVariant = (baseId, ovDir, baseName, precision, sizeMB, legacy = false) => ({
1186
1187
  id: `${baseId}-${precision}`,
1187
1188
  name: `${baseName} (${precision.toUpperCase()})`,
1188
1189
  description: `${baseName} — OpenVINO ${precision.toUpperCase()} variant for Intel iGPU/NPU; scale by hardware`,
@@ -1192,7 +1193,13 @@ var ovPrecisionVariant = (baseId, ovDir, baseName, precision, sizeMB) => ({
1192
1193
  },
1193
1194
  labels: [],
1194
1195
  preprocessMode: "letterbox",
1195
- formats: { openvino: ovFormat(hf(`${ovDir}/camstack-${baseId}-${precision}.xml`), sizeMB) }
1196
+ formats: { openvino: ovFormat(hf(`${ovDir}/camstack-${baseId}-${precision}.xml`), sizeMB) },
1197
+ ...legacy ? { legacy: true } : {},
1198
+ ...precision === "int8" ? { group: {
1199
+ family: baseId.slice(0, -1),
1200
+ tier: baseId.slice(-1),
1201
+ precision: "int8"
1202
+ } } : {}
1196
1203
  });
1197
1204
  var MLPACKAGE_FILES = [
1198
1205
  "Manifest.json",
@@ -1202,6 +1209,7 @@ var MLPACKAGE_FILES = [
1202
1209
  var OBJECT_DETECTION_MODELS = [
1203
1210
  {
1204
1211
  id: "yolov9t",
1212
+ legacy: true,
1205
1213
  name: "YOLOv9 Tiny",
1206
1214
  description: "YOLOv9 Tiny — ultra-lightweight next-gen detector",
1207
1215
  inputSize: {
@@ -1227,6 +1235,7 @@ var OBJECT_DETECTION_MODELS = [
1227
1235
  },
1228
1236
  {
1229
1237
  id: "yolov9s",
1238
+ legacy: true,
1230
1239
  name: "YOLOv9 Small",
1231
1240
  description: "YOLOv9 Small — improved efficiency over YOLOv8s",
1232
1241
  inputSize: {
@@ -1252,6 +1261,7 @@ var OBJECT_DETECTION_MODELS = [
1252
1261
  },
1253
1262
  {
1254
1263
  id: "yolov9c",
1264
+ legacy: true,
1255
1265
  name: "YOLOv9 C",
1256
1266
  description: "YOLOv9 C — high-accuracy compact model",
1257
1267
  inputSize: {
@@ -1277,6 +1287,10 @@ var OBJECT_DETECTION_MODELS = [
1277
1287
  },
1278
1288
  {
1279
1289
  id: "yolo26n",
1290
+ group: {
1291
+ family: "yolo26",
1292
+ tier: "n"
1293
+ },
1280
1294
  name: "YOLO26 Nano",
1281
1295
  description: "YOLO26 Nano — latest generation ultra-lightweight detector with attention mechanism",
1282
1296
  inputSize: {
@@ -1302,6 +1316,10 @@ var OBJECT_DETECTION_MODELS = [
1302
1316
  },
1303
1317
  {
1304
1318
  id: "yolo26s",
1319
+ group: {
1320
+ family: "yolo26",
1321
+ tier: "s"
1322
+ },
1305
1323
  name: "YOLO26 Small",
1306
1324
  description: "YOLO26 Small — balanced speed/accuracy with attention-based architecture",
1307
1325
  inputSize: {
@@ -1327,6 +1345,10 @@ var OBJECT_DETECTION_MODELS = [
1327
1345
  },
1328
1346
  {
1329
1347
  id: "yolo26m",
1348
+ group: {
1349
+ family: "yolo26",
1350
+ tier: "m"
1351
+ },
1330
1352
  name: "YOLO26 Medium",
1331
1353
  description: "YOLO26 Medium — high accuracy general-purpose detector",
1332
1354
  inputSize: {
@@ -1352,6 +1374,10 @@ var OBJECT_DETECTION_MODELS = [
1352
1374
  },
1353
1375
  {
1354
1376
  id: "yolo26l",
1377
+ group: {
1378
+ family: "yolo26",
1379
+ tier: "l"
1380
+ },
1355
1381
  name: "YOLO26 Large",
1356
1382
  description: "YOLO26 Large — maximum accuracy for demanding deployments",
1357
1383
  inputSize: {
@@ -1377,6 +1403,7 @@ var OBJECT_DETECTION_MODELS = [
1377
1403
  },
1378
1404
  {
1379
1405
  id: "yolo26x",
1406
+ legacy: true,
1380
1407
  name: "YOLO26 XLarge",
1381
1408
  description: "YOLO26 XLarge — highest accuracy, attention-based architecture",
1382
1409
  inputSize: {
@@ -1402,6 +1429,7 @@ var OBJECT_DETECTION_MODELS = [
1402
1429
  },
1403
1430
  {
1404
1431
  id: "scrypted-yolov9t-relu",
1432
+ legacy: true,
1405
1433
  name: "YOLOv9 Tiny ReLU",
1406
1434
  description: "YOLOv9t ReLU — optimized for int8 quantization",
1407
1435
  inputSize: {
@@ -1427,6 +1455,7 @@ var OBJECT_DETECTION_MODELS = [
1427
1455
  },
1428
1456
  {
1429
1457
  id: "scrypted-yolov9s-relu",
1458
+ legacy: true,
1430
1459
  name: "YOLOv9 Small ReLU",
1431
1460
  description: "YOLOv9s ReLU — improved efficiency, int8 ready",
1432
1461
  inputSize: {
@@ -1452,6 +1481,7 @@ var OBJECT_DETECTION_MODELS = [
1452
1481
  },
1453
1482
  {
1454
1483
  id: "scrypted-yolov9c-relu",
1484
+ legacy: true,
1455
1485
  name: "YOLOv9 C ReLU",
1456
1486
  description: "YOLOv9c ReLU — high-accuracy, int8 ready",
1457
1487
  inputSize: {
@@ -1477,6 +1507,7 @@ var OBJECT_DETECTION_MODELS = [
1477
1507
  },
1478
1508
  {
1479
1509
  id: "scrypted-yolov9m-relu",
1510
+ legacy: true,
1480
1511
  name: "YOLOv9 M ReLU",
1481
1512
  description: "YOLOv9m ReLU — medium, int8 ready",
1482
1513
  inputSize: {
@@ -1500,20 +1531,20 @@ var OBJECT_DETECTION_MODELS = [
1500
1531
  openvino: ovFormat(hf("objectDetection/scrypted-yolov9-relu/openvino/scrypted_yolov9m_relu.xml"), 38)
1501
1532
  }
1502
1533
  },
1503
- ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "fp16", 5),
1504
- ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "int8", 3),
1505
- ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "fp16", 15),
1506
- ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "int8", 8),
1507
- ovPrecisionVariant("yolo26n", "objectDetection/yolo26/openvino", "YOLO26 Nano", "fp16", 5),
1534
+ ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "fp16", 5, true),
1535
+ ovPrecisionVariant("yolov9t", "objectDetection/yolov9/openvino", "YOLOv9 Tiny", "int8", 3, true),
1536
+ ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "fp16", 15, true),
1537
+ ovPrecisionVariant("yolov9s", "objectDetection/yolov9/openvino", "YOLOv9 Small", "int8", 8, true),
1538
+ ovPrecisionVariant("yolo26n", "objectDetection/yolo26/openvino", "YOLO26 Nano", "fp16", 5, true),
1508
1539
  ovPrecisionVariant("yolo26n", "objectDetection/yolo26/openvino", "YOLO26 Nano", "int8", 3),
1509
- ovPrecisionVariant("yolo26s", "objectDetection/yolo26/openvino", "YOLO26 Small", "fp16", 19),
1540
+ ovPrecisionVariant("yolo26s", "objectDetection/yolo26/openvino", "YOLO26 Small", "fp16", 19, true),
1510
1541
  ovPrecisionVariant("yolo26s", "objectDetection/yolo26/openvino", "YOLO26 Small", "int8", 10),
1511
- ovPrecisionVariant("yolo26m", "objectDetection/yolo26/openvino", "YOLO26 Medium", "fp16", 41),
1542
+ ovPrecisionVariant("yolo26m", "objectDetection/yolo26/openvino", "YOLO26 Medium", "fp16", 41, true),
1512
1543
  ovPrecisionVariant("yolo26m", "objectDetection/yolo26/openvino", "YOLO26 Medium", "int8", 21),
1513
- ovPrecisionVariant("yolo26l", "objectDetection/yolo26/openvino", "YOLO26 Large", "fp16", 50),
1544
+ ovPrecisionVariant("yolo26l", "objectDetection/yolo26/openvino", "YOLO26 Large", "fp16", 50, true),
1514
1545
  ovPrecisionVariant("yolo26l", "objectDetection/yolo26/openvino", "YOLO26 Large", "int8", 25),
1515
- ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "fp16", 112),
1516
- ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "int8", 56)
1546
+ ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "fp16", 112, true),
1547
+ ovPrecisionVariant("yolo26x", "objectDetection/yolo26/openvino", "YOLO26 XLarge", "int8", 56, true)
1517
1548
  ];
1518
1549
  var FACE_DETECTION_MODELS = [{
1519
1550
  id: "scrfd-2.5g",
@@ -2379,17 +2410,58 @@ function getStepDefinition(stepId) {
2379
2410
  }
2380
2411
  /**
2381
2412
  * Get the default model ID for a step given the current model format.
2382
- * Selects the smallest model that supports the format.
2383
- * Falls back to StepDefinition.defaultModelId if no format-specific model found.
2413
+ *
2414
+ * Resolution order:
2415
+ * 1. `def.defaultModelIdByFormat?.[format]` — the step's declared
2416
+ * per-format preference — if that model exists in `def.models` AND
2417
+ * has a build for `format`.
2418
+ * 2. `def.defaultModelId` — the step's plain declared default — if it
2419
+ * exists in `def.models` AND has a build for `format`.
2420
+ * 3. The smallest-by-size model among those with a `format` build
2421
+ * (legacy fallback, preserved for steps/formats with no declared
2422
+ * preference reachable).
2423
+ * 4. `def.defaultModelId` unchanged, when ZERO models have a `format`
2424
+ * build — an unloadable case flagged elsewhere, not resolved here.
2384
2425
  */
2385
2426
  function getDefaultModelForFormat(stepId, format) {
2386
2427
  const def = getStepDefinition(stepId);
2387
- const available = def.models.filter((m) => m.formats[format]);
2428
+ const hasFormatBuild = (modelId) => def.models.find((m) => m.id === modelId)?.formats[format] !== void 0;
2429
+ const declaredForFormat = def.defaultModelIdByFormat?.[format];
2430
+ if (declaredForFormat !== void 0 && hasFormatBuild(declaredForFormat)) return declaredForFormat;
2431
+ if (hasFormatBuild(def.defaultModelId)) return def.defaultModelId;
2432
+ const available = def.models.filter((m) => m.formats[format] && m.legacy !== true);
2388
2433
  if (available.length === 0) return def.defaultModelId;
2389
2434
  return [...available].toSorted((a, b) => {
2390
2435
  return (a.formats[format]?.sizeMB ?? Infinity) - (b.formats[format]?.sizeMB ?? Infinity);
2391
2436
  })[0].id;
2392
2437
  }
2438
+ /**
2439
+ * The single node-local authority for turning a CHOSEN model (or none) into a
2440
+ * concrete model that has a build for this node's engine `format`. Fuses the
2441
+ * two previously-duplicated decisions (orchestrator `pickDefaultModelId` +
2442
+ * provider `substituteIncompatibleModels`):
2443
+ *
2444
+ * - chosen === null → the addon's default model for `format`.
2445
+ * - chosen is a catalog model → keep it if it has a `format` build; else
2446
+ * the addon's default model for `format`
2447
+ * (a substitution — the caller surfaces it).
2448
+ * - chosen is NOT in the catalog (custom/unknown) → returned unchanged; the
2449
+ * operator owns a custom model's format compat.
2450
+ * - unknown stepId → chosen returned unchanged (never throws).
2451
+ */
2452
+ function resolveModelForFormat(stepId, chosenModelId, format) {
2453
+ let def;
2454
+ try {
2455
+ def = getStepDefinition(stepId);
2456
+ } catch {
2457
+ return chosenModelId ?? "";
2458
+ }
2459
+ if (chosenModelId === null) return getDefaultModelForFormat(stepId, format);
2460
+ const entry = def.models.find((m) => m.id === chosenModelId);
2461
+ if (!entry) return chosenModelId;
2462
+ if (entry.formats[format]) return chosenModelId;
2463
+ return getDefaultModelForFormat(stepId, format);
2464
+ }
2393
2465
  //#endregion
2394
2466
  //#region src/detection-pipeline/engine/engine-factory.ts
2395
2467
  var BACKEND_TO_POOL_RUNTIME = {
@@ -4208,6 +4280,147 @@ function mergeCustomModels(staticModels, customModels) {
4208
4280
  return merged;
4209
4281
  }
4210
4282
  //#endregion
4283
+ //#region src/detection-pipeline/step-resolution.ts
4284
+ /**
4285
+ * Walk `steps` (recursing into children) and return exactly the
4286
+ * substitutions in effect for `format` — one entry per step whose chosen
4287
+ * model differs from its resolved model. Pure and deterministic: the same
4288
+ * `(steps, format)` always yields the same result, with no entries left over
4289
+ * from a prior format. Callers that cache substitutions (e.g. for a status
4290
+ * surface) should REBUILD their cache from this result on every resolve
4291
+ * pass rather than merging into it, so a step that becomes compatible again
4292
+ * (chosen === resolved) is dropped instead of going stale.
4293
+ */
4294
+ function collectSubstitutions(steps, format) {
4295
+ const result = [];
4296
+ for (const step of steps) {
4297
+ const chosen = step.modelId;
4298
+ if (chosen != null) {
4299
+ const running = resolveModelForFormat(step.addonId, chosen, format);
4300
+ if (running !== chosen) result.push({
4301
+ addonId: step.addonId,
4302
+ chosen,
4303
+ running,
4304
+ format
4305
+ });
4306
+ }
4307
+ if (step.children?.length) result.push(...collectSubstitutions(step.children, format));
4308
+ }
4309
+ return result;
4310
+ }
4311
+ /**
4312
+ * Return one {@link ZeroBuildIssue} per step in `steps` whose catalog entry
4313
+ * (via `getStepDef`) has ZERO models with a build for `format`. `steps` is
4314
+ * expected to already be flattened + filtered to the set the caller cares
4315
+ * about (e.g. enabled video steps) — this does not recurse into children or
4316
+ * filter by `enabled`, unlike {@link collectSubstitutions}.
4317
+ *
4318
+ * `getStepDef` defaults to the real catalog lookup and is injectable so
4319
+ * tests can exercise the zero-build branch with a fabricated
4320
+ * `StepDefinition` — the real catalog currently has no step whose EVERY
4321
+ * model lacks a given format, so this branch is otherwise unreachable in a
4322
+ * black-box test. Unknown/custom addonIds (no registered `StepDefinition`)
4323
+ * are skipped, never thrown.
4324
+ */
4325
+ function collectZeroBuildIssues(steps, format, getStepDef = getStepDefinition) {
4326
+ const result = [];
4327
+ for (const step of steps) {
4328
+ let def;
4329
+ try {
4330
+ def = getStepDef(step.addonId);
4331
+ } catch {
4332
+ continue;
4333
+ }
4334
+ if (!def.models.some((m) => m.formats[format])) result.push({
4335
+ addonId: step.addonId,
4336
+ format
4337
+ });
4338
+ }
4339
+ return result;
4340
+ }
4341
+ /**
4342
+ * Walk `steps` (recursing into children, mirroring {@link collectSubstitutions}'
4343
+ * recursion — every step in the tree is checked regardless of `enabled`) and
4344
+ * return one {@link UnknownAddonIssue} per addonId whose `getStepDef` lookup
4345
+ * throws. Pure, deterministic, never throws itself.
4346
+ *
4347
+ * `getStepDef` defaults to the real catalog lookup and is injectable so
4348
+ * tests can exercise the unknown-addon branch with a fabricated resolver
4349
+ * without `vi.mock`.
4350
+ */
4351
+ function collectUnknownAddonIssues(steps, getStepDef = getStepDefinition) {
4352
+ const result = [];
4353
+ for (const step of steps) {
4354
+ try {
4355
+ getStepDef(step.addonId);
4356
+ } catch {
4357
+ result.push({ addonId: step.addonId });
4358
+ }
4359
+ if (step.children?.length) result.push(...collectUnknownAddonIssues(step.children, getStepDef));
4360
+ }
4361
+ return result;
4362
+ }
4363
+ /**
4364
+ * Enrich `PipelineStepInput[]` (the wire shape a camera pipeline sends) into
4365
+ * `PipelineDefaultStep[]` (the catalog-enriched shape the executor consumes),
4366
+ * resolving each step's model for `format` via `resolveModelForFormat`.
4367
+ *
4368
+ * Pure and side-effect-free — never throws. This is the seam behind the
4369
+ * LIVE per-camera dispatch path (`DetectionPipelineProvider.
4370
+ * inputStepsToPipelineSteps`), which runs once per decoded frame (up to
4371
+ * detectionFps × N cameras/sec): a bad step config referencing an unknown
4372
+ * `addonId` must never become a per-frame throw. Unknown addons are
4373
+ * dropped from the returned tree and reported in
4374
+ * `diagnostics.unknownAddonIds`; model substitutions are reported in
4375
+ * `diagnostics.substitutions` instead of happening silently. The caller
4376
+ * (the provider) owns logging + dedup for both.
4377
+ */
4378
+ function resolveInputSteps(steps, format) {
4379
+ const resolvedSteps = [];
4380
+ const unknownAddonIds = [];
4381
+ const substitutions = [];
4382
+ for (const s of steps) {
4383
+ let def;
4384
+ try {
4385
+ def = getStepDefinition(s.addonId);
4386
+ } catch {
4387
+ unknownAddonIds.push(s.addonId);
4388
+ continue;
4389
+ }
4390
+ const chosenModelId = s.modelId ?? null;
4391
+ const runningModelId = resolveModelForFormat(s.addonId, chosenModelId, format);
4392
+ if (chosenModelId !== null && runningModelId !== chosenModelId) substitutions.push({
4393
+ addonId: s.addonId,
4394
+ chosen: chosenModelId,
4395
+ running: runningModelId,
4396
+ format
4397
+ });
4398
+ const childResult = s.children ? resolveInputSteps(s.children, format) : null;
4399
+ if (childResult) {
4400
+ unknownAddonIds.push(...childResult.diagnostics.unknownAddonIds);
4401
+ substitutions.push(...childResult.diagnostics.substitutions);
4402
+ }
4403
+ resolvedSteps.push({
4404
+ addonId: s.addonId,
4405
+ addonName: def.name,
4406
+ slot: def.slot,
4407
+ inputClasses: def.inputClasses ?? [],
4408
+ outputClasses: def.outputClasses,
4409
+ enabled: s.enabled,
4410
+ modelId: runningModelId,
4411
+ children: childResult ? childResult.steps : [],
4412
+ ...s.settings ? { settings: { ...s.settings } } : {}
4413
+ });
4414
+ }
4415
+ return {
4416
+ steps: resolvedSteps,
4417
+ diagnostics: {
4418
+ unknownAddonIds,
4419
+ substitutions
4420
+ }
4421
+ };
4422
+ }
4423
+ //#endregion
4211
4424
  //#region src/detection-pipeline/provider.ts
4212
4425
  /**
4213
4426
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4504,6 +4717,15 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4504
4717
  */
4505
4718
  lastStepsSignature = null;
4506
4719
  /**
4720
+ * Dedup guard for the live per-camera dispatch path
4721
+ * (`inputStepsToPipelineSteps`) — an unknown-addon skip or a runtime
4722
+ * model substitution logs ONCE per distinct signature instead of once
4723
+ * per decoded frame (that method runs up to detectionFps × N cameras
4724
+ * times/sec). Never cleared — it's a log-noise guard, not runtime
4725
+ * state, and the key space (addonId/model combos) is small and stable.
4726
+ */
4727
+ loggedLiveDispatchIssues = /* @__PURE__ */ new Set();
4728
+ /**
4507
4729
  * CB5 shm passthrough — the process-local, LONG-LIVED reader cache for
4508
4730
  * `runPipeline({ frameHandle })`. Opens each named shm segment ONCE and
4509
4731
  * reuses the mapping for every later handle on the same ring (never
@@ -4532,6 +4754,17 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4532
4754
  */
4533
4755
  provisioner = null;
4534
4756
  /**
4757
+ * Gate A (config-correctness gate at engine change). Human-readable config
4758
+ * issues — model substitutions and zero-build steps — for the CURRENT
4759
+ * node-default tree against `this.currentEngine.format`. Recomputed
4760
+ * EAGERLY by {@link recomputeConfigIssues} whenever the engine actually
4761
+ * changes (see `onEngineSelectionChanged`), rather than relying on a
4762
+ * `getGlobalSteps()` call that may never happen on a camera-only node.
4763
+ * Merged into {@link getEngineProvisioning}'s snapshot; never consulted by
4764
+ * `assertEngineReady` — this gate SURFACES, it does not block inference.
4765
+ */
4766
+ configIssues = [];
4767
+ /**
4535
4768
  * True when `init()` found no persisted engine choice (first boot).
4536
4769
  * `setApi()` reads this flag and auto-picks the best supported runtime
4537
4770
  * using probe data (hardware + bestScore hint), persists the selection,
@@ -4778,7 +5011,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4778
5011
  const stored = await this.loadEngine();
4779
5012
  if (stored) this.currentEngine = stored;
4780
5013
  else await this.resolveAutoEngine();
4781
- if ((prev.runtime !== this.currentEngine.runtime || prev.backend !== this.currentEngine.backend || prev.format !== this.currentEngine.format || (prev.device ?? "") !== (this.currentEngine.device ?? "")) && this.engineFactory) {
5014
+ const engineChanged = prev.runtime !== this.currentEngine.runtime || prev.backend !== this.currentEngine.backend || prev.format !== this.currentEngine.format || (prev.device ?? "") !== (this.currentEngine.device ?? "");
5015
+ if (engineChanged && this.engineFactory) {
4782
5016
  this.log.info("engine selection changed — rebuilding pool in place", { meta: {
4783
5017
  from: `${prev.backend}/${prev.device ?? "default"}`,
4784
5018
  to: `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`
@@ -4790,18 +5024,56 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4790
5024
  this.executor = null;
4791
5025
  }
4792
5026
  this.startProvisioningForCurrentEngine();
5027
+ if (engineChanged) await this.recomputeConfigIssues();
5028
+ }
5029
+ /**
5030
+ * Gate A (config-correctness gate at engine change) — eagerly re-resolve
5031
+ * the node-default step tree for `this.currentEngine.format` and compute
5032
+ * human-readable config issues from the result:
5033
+ * - model substitutions (`getActiveModelSubstitutions()`, populated as a
5034
+ * side effect of the `getGlobalSteps()` re-resolve below) — a step's
5035
+ * chosen model lacks a build for this format, so the format default
5036
+ * runs instead (degraded but loadable).
5037
+ * - zero-build steps (`collectZeroBuildIssues`) — a step whose FULL
5038
+ * catalog has no model at all with a build for this format (not even
5039
+ * the format default is loadable).
5040
+ *
5041
+ * Never throws — this is an additive diagnostic surface, not an
5042
+ * enforcement point (`assertEngineReady` still owns readiness). Any
5043
+ * failure clears `configIssues` and logs a warning rather than
5044
+ * propagating out of the engine-change path.
5045
+ */
5046
+ async recomputeConfigIssues() {
5047
+ this.configIssues = [];
5048
+ try {
5049
+ const steps = await this.getGlobalSteps();
5050
+ if (!steps) return;
5051
+ const format = this.currentEngine.format;
5052
+ const substitutionIssues = this.getActiveModelSubstitutions().map((s) => `${s.addonId}: chose ${s.chosen}, running ${s.running} (${s.format})`);
5053
+ const zeroBuildIssues = collectZeroBuildIssues(flattenSteps(steps), format).map((i) => `${i.addonId}: no model has a ${i.format} build`);
5054
+ this.configIssues = [...substitutionIssues, ...zeroBuildIssues];
5055
+ } catch (err) {
5056
+ this.configIssues = [];
5057
+ this.log.warn("engine-change config gate failed — no config issues surfaced", { meta: { error: require_dist.errMsg(err) } });
5058
+ }
4793
5059
  }
4794
5060
  /**
4795
5061
  * Per-node engine-provisioning snapshot. The cap routes `{ nodeId }` for
4796
5062
  * provider resolution only (the router strips it); each node returns its
4797
- * own local machine state. Cold (pre-`setApi`) provider → `idle`.
5063
+ * own local machine state. Cold (pre-`setApi`) provider → `idle`. Merges
5064
+ * in Gate A's `configIssues` (see {@link recomputeConfigIssues}) when
5065
+ * non-empty — additive, never overrides the provisioner's own fields.
4798
5066
  */
4799
5067
  getEngineProvisioning() {
4800
- return this.provisioner?.state ?? {
5068
+ const base = this.provisioner?.state ?? {
4801
5069
  runtimeId: null,
4802
5070
  device: null,
4803
5071
  state: "idle"
4804
5072
  };
5073
+ return this.configIssues.length ? {
5074
+ ...base,
5075
+ configIssues: [...this.configIssues]
5076
+ } : base;
4805
5077
  }
4806
5078
  /**
4807
5079
  * Inference gate. Rejects with the current phase/error unless the engine
@@ -5000,43 +5272,45 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5000
5272
  return buildDefaultStepTree(engine.format);
5001
5273
  }
5002
5274
  /**
5003
- * Substitute, AT RUNTIME ONLY (never persisted), any step whose configured
5004
- * model lacks a build for that step's active engine format. A GLOBAL pipeline
5005
- * config can pin a model that's valid on the cluster's Intel nodes (e.g. the
5006
- * OpenVINO-only `yolov9t-int8`) but impossible on a CoreML/ONNX node — without
5007
- * this the node crash-loops `Model "X" has no <format> format`. The operator's
5008
- * choice stays in the persisted config; this node just runs the smallest
5009
- * catalog model that DOES have its format. Mirrors the per-node engine
5010
- * fallback in `loadEngine`. Catalog models only a custom model that lacks
5011
- * the format is left as-is (operator's responsibility).
5275
+ * Resolve every step's model for this node's engine format via the shared
5276
+ * `resolveModelForFormat`, and surface any substitution. `step.engine?.format`
5277
+ * is gone `this.currentEngine.format` is the only format source (engine is
5278
+ * node-local).
5279
+ *
5280
+ * Rebuilds `modelSubstitutions` from scratch (via {@link collectSubstitutions})
5281
+ * on every call, from the FULL tree being resolved, so the map always
5282
+ * reflects EXACTLY the substitutions in effect for the current tree+format
5283
+ * a step whose chosen model becomes compatible again (e.g. after the
5284
+ * operator switches the engine to a format it supports) is cleared here
5285
+ * instead of leaking a stale "chose X → running Y" entry.
5012
5286
  */
5013
- substituteIncompatibleModels(steps) {
5014
- const fix = (step) => {
5015
- const format = step.engine?.format ?? this.currentEngine.format;
5016
- let modelId = step.modelId;
5017
- try {
5018
- const entry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
5019
- if (entry && !entry.formats[format]) {
5020
- const fallback = getDefaultModelForFormat(step.addonId, format);
5021
- if (fallback !== step.modelId) {
5022
- this.log.info("Step model lacks engine format — substituting format default (runtime)", { meta: {
5023
- step: step.addonId,
5024
- configured: step.modelId,
5025
- substitute: fallback,
5026
- format
5027
- } });
5028
- modelId = fallback;
5029
- }
5030
- }
5031
- } catch {}
5032
- const children = step.children?.length ? step.children.map(fix) : step.children;
5033
- return modelId === step.modelId && children === step.children ? step : {
5287
+ resolveStepModels(steps) {
5288
+ const format = this.currentEngine.format;
5289
+ const substitutions = collectSubstitutions(steps, format);
5290
+ this.modelSubstitutions = new Map(substitutions.map((s) => [s.addonId, s]));
5291
+ for (const s of substitutions) this.log.info("Step model lacks engine format — running format default (node-local)", { meta: {
5292
+ step: s.addonId,
5293
+ chosen: s.chosen,
5294
+ running: s.running,
5295
+ format: s.format
5296
+ } });
5297
+ return this.resolveStepModelTree(steps, format);
5298
+ }
5299
+ /** Pure recursive resolver — builds the resolved step tree without touching `modelSubstitutions`. */
5300
+ resolveStepModelTree(steps, format) {
5301
+ return steps.map((step) => {
5302
+ const resolved = resolveModelForFormat(step.addonId, step.modelId ?? null, format);
5303
+ const children = step.children?.length ? this.resolveStepModelTree([...step.children], format) : step.children;
5304
+ return resolved === step.modelId && children === step.children ? step : {
5034
5305
  ...step,
5035
- modelId,
5306
+ modelId: resolved,
5036
5307
  ...children ? { children } : {}
5037
5308
  };
5038
- };
5039
- return steps.map(fix);
5309
+ });
5310
+ }
5311
+ modelSubstitutions = /* @__PURE__ */ new Map();
5312
+ getActiveModelSubstitutions() {
5313
+ return [...this.modelSubstitutions.values()];
5040
5314
  }
5041
5315
  async getGlobalSteps() {
5042
5316
  if (this.currentSteps) return this.currentSteps;
@@ -5044,7 +5318,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5044
5318
  if (!raw) {
5045
5319
  const defaults = buildDefaultStepTree(this.currentEngine.format);
5046
5320
  if (defaults.length === 0) return null;
5047
- this.currentSteps = this.substituteIncompatibleModels(defaults);
5321
+ this.currentSteps = this.resolveStepModels(defaults);
5048
5322
  this.writeStore({ [KEY_STEPS]: JSON.stringify(defaults) });
5049
5323
  this.log.info("Bootstrapped default pipeline — object-detection + face + plate recognition enabled by default", { meta: { rootSteps: defaults.length } });
5050
5324
  return this.currentSteps;
@@ -5057,15 +5331,6 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5057
5331
  const audioDef = getStepDefinition("audio-classifier");
5058
5332
  if (audioDef.models.some((m) => m.formats[format])) {
5059
5333
  const modelId = getDefaultModelForFormat("audio-classifier", format);
5060
- const audioEngine = modelId === "apple-soundanalysis" ? {
5061
- runtime: "python",
5062
- backend: "coreml",
5063
- format: "coreml"
5064
- } : {
5065
- runtime: "python",
5066
- backend: "cpu",
5067
- format: "onnx"
5068
- };
5069
5334
  const audioStep = {
5070
5335
  addonId: audioDef.id,
5071
5336
  addonName: audioDef.name,
@@ -5074,15 +5339,14 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5074
5339
  outputClasses: audioDef.outputClasses,
5075
5340
  enabled: true,
5076
5341
  modelId,
5077
- children: [],
5078
- engine: audioEngine
5342
+ children: []
5079
5343
  };
5080
5344
  steps = [...steps, audioStep];
5081
5345
  this.writeStore({ [KEY_STEPS]: JSON.stringify(steps) });
5082
5346
  this.log.info("Migration: added audio-classifier step to persisted pipeline config");
5083
5347
  }
5084
5348
  }
5085
- this.currentSteps = this.substituteIncompatibleModels(steps);
5349
+ this.currentSteps = this.resolveStepModels(steps);
5086
5350
  return this.currentSteps;
5087
5351
  } catch {
5088
5352
  throw new Error(`Failed to parse persisted pipeline steps: corrupt data in key "${KEY_STEPS}"`);
@@ -5092,6 +5356,128 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5092
5356
  if (!this.currentSteps) return null;
5093
5357
  return stepsToPipelineConfig(this.currentSteps, this.currentEngine);
5094
5358
  }
5359
+ /**
5360
+ * Task G5 (Gate B) — pre-init config-correctness gate. PURE COMPUTE
5361
+ * against `this.currentEngine.format`: resolves `steps` the same way the
5362
+ * runtime dispatch path (`resolveInputSteps`) would and reports what
5363
+ * WOULD happen, without touching `this.modelSubstitutions` (that node-
5364
+ * global map stays the exclusive property of `resolveStepModels`, the
5365
+ * node-default tree resolver — writing it here would race two
5366
+ * independent callers over the same field). Called by the orchestrator
5367
+ * at attach time, node-pinned to the target node.
5368
+ *
5369
+ * Composes three pure helpers from `step-resolution.ts`, ALL scoped to
5370
+ * ENABLED steps only (a disabled step's subtree — and any unknown addonId
5371
+ * or incompatible model inside it — contributes NO issue and NO
5372
+ * substitution; it will never execute, so flagging it is false-positive
5373
+ * noise):
5374
+ * - `collectSubstitutions` → `substitutions` (informational, never
5375
+ * affects `ok`).
5376
+ * - `collectUnknownAddonIssues` → `unknown-addon` HARD issues.
5377
+ * - `collectZeroBuildIssues` (over the enabled, non-audio-classifier
5378
+ * subset — mirrors Gate A's `flattenSteps`/`flattenEnabledVideoSteps`
5379
+ * filter, since audio backends are validated by audio-analyzer, not
5380
+ * against this node's VIDEO engine format) → `no-format-build` HARD
5381
+ * issues.
5382
+ *
5383
+ * `collectSubstitutions` and `collectUnknownAddonIssues` recurse into
5384
+ * `children` themselves, so the enabled-scoping is applied ONCE up front
5385
+ * via {@link pruneToEnabledSteps} (which drops a disabled step's whole
5386
+ * subtree) rather than re-implemented inside each collector.
5387
+ *
5388
+ * Never throws — any unexpected failure degrades to a clean `ok: true`
5389
+ * result with a warning logged, same posture as `recomputeConfigIssues`.
5390
+ */
5391
+ async validatePipeline(input) {
5392
+ const format = this.currentEngine.format;
5393
+ try {
5394
+ const steps = input.steps;
5395
+ const enabledSteps = this.pruneToEnabledSteps(steps);
5396
+ const substitutions = collectSubstitutions(enabledSteps, format);
5397
+ const unknownAddonIssues = collectUnknownAddonIssues(enabledSteps);
5398
+ const zeroBuildIssues = collectZeroBuildIssues(this.flattenEnabledVideoStepInputs(steps), format);
5399
+ const issues = [...unknownAddonIssues.map((u) => ({
5400
+ addonId: u.addonId,
5401
+ kind: "unknown-addon",
5402
+ detail: `${u.addonId}: no registered step definition`
5403
+ })), ...zeroBuildIssues.map((z) => ({
5404
+ addonId: z.addonId,
5405
+ kind: "no-format-build",
5406
+ detail: `${z.addonId}: no model has a ${z.format} build`
5407
+ }))];
5408
+ return {
5409
+ ok: issues.length === 0,
5410
+ issues,
5411
+ substitutions,
5412
+ format
5413
+ };
5414
+ } catch (err) {
5415
+ this.log.warn("validatePipeline failed — reporting a clean result", { meta: { error: require_dist.errMsg(err) } });
5416
+ return {
5417
+ ok: true,
5418
+ issues: [],
5419
+ substitutions: [],
5420
+ format
5421
+ };
5422
+ }
5423
+ }
5424
+ /**
5425
+ * Prune `steps` to ENABLED steps only, recursing into ENABLED children —
5426
+ * a disabled step's whole subtree is dropped, never partially kept. This
5427
+ * is the enabled-only tree shape `collectSubstitutions` and
5428
+ * `collectUnknownAddonIssues` need in {@link validatePipeline}: both
5429
+ * recurse into `children` themselves (unlike `flattenEnabledVideoStepInputs`,
5430
+ * which flattens AND excludes `audio-classifier`), so pruning once up
5431
+ * front — without touching the audio/video split — is enough to make
5432
+ * Gate B's three checks agree on scope. Pure; returns the original array
5433
+ * reference when nothing changed.
5434
+ */
5435
+ pruneToEnabledSteps(steps) {
5436
+ const result = [];
5437
+ let changed = false;
5438
+ for (const step of steps) {
5439
+ if (!step.enabled) {
5440
+ changed = true;
5441
+ continue;
5442
+ }
5443
+ const prunedChildren = step.children?.length ? this.pruneToEnabledSteps(step.children) : step.children;
5444
+ if (prunedChildren !== step.children) {
5445
+ changed = true;
5446
+ result.push({
5447
+ ...step,
5448
+ children: prunedChildren
5449
+ });
5450
+ } else result.push(step);
5451
+ }
5452
+ return changed ? result : steps;
5453
+ }
5454
+ /**
5455
+ * Flatten `steps` (recursing into ENABLED children only — a disabled
5456
+ * step's subtree is skipped entirely, mirroring
5457
+ * `flattenEnabledVideoSteps`) into `{addonId}` entries, excluding
5458
+ * `audio-classifier` steps: Gate B validates the VIDEO engine format
5459
+ * (`this.currentEngine.format`) only — audio backends resolve their own
5460
+ * format independently and are validated by the audio-analyzer addon.
5461
+ * An addonId whose `StepDefinition` lookup throws is skipped here (it is
5462
+ * already reported separately by `collectUnknownAddonIssues`).
5463
+ */
5464
+ flattenEnabledVideoStepInputs(steps) {
5465
+ const result = [];
5466
+ const walk = (nodes) => {
5467
+ for (const step of nodes) {
5468
+ if (!step.enabled) continue;
5469
+ try {
5470
+ if (getStepDefinition(step.addonId).slot === "audio-classifier") continue;
5471
+ } catch {
5472
+ continue;
5473
+ }
5474
+ result.push({ addonId: step.addonId });
5475
+ if (step.children?.length) walk(step.children);
5476
+ }
5477
+ };
5478
+ walk(steps);
5479
+ return result;
5480
+ }
5095
5481
  async getSelectedEngine() {
5096
5482
  return this.currentEngine;
5097
5483
  }
@@ -5104,7 +5490,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5104
5490
  const addons = ALL_PIPELINE_STEPS.map((s) => ({
5105
5491
  id: s.definition.id,
5106
5492
  name: s.definition.name,
5107
- models: s.definition.models.map((m) => {
5493
+ models: s.definition.models.filter((m) => m.legacy !== true).map((m) => {
5108
5494
  const formats = {};
5109
5495
  for (const [formatKey, entry] of Object.entries(m.formats)) {
5110
5496
  if (!entry) continue;
@@ -5418,7 +5804,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5418
5804
  emit(`Image: ${imageWidth}×${imageHeight}`);
5419
5805
  }
5420
5806
  decodeMs = performance.now() - decodeT0;
5421
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps);
5807
+ const resolveFormat = input.engine?.format ?? this.currentEngine.format;
5808
+ const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat);
5422
5809
  const stepsSignature = JSON.stringify(benchmarkSteps.map((s) => ({
5423
5810
  id: s.addonId,
5424
5811
  settings: s.settings ?? {}
@@ -5707,22 +6094,46 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5707
6094
  this.log.warn("Override cache dispose failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
5708
6095
  }
5709
6096
  }
5710
- /** Convert PipelineStepInput[] → PipelineDefaultStep[] by enriching from StepDefinitions */
5711
- inputStepsToPipelineSteps(steps) {
5712
- return steps.map((s) => {
5713
- const def = getStepDefinition(s.addonId);
5714
- return {
5715
- addonId: s.addonId,
5716
- addonName: def.name,
5717
- slot: def.slot,
5718
- inputClasses: def.inputClasses ?? [],
5719
- outputClasses: def.outputClasses,
5720
- enabled: s.enabled,
5721
- modelId: s.modelId,
5722
- children: s.children ? this.inputStepsToPipelineSteps(s.children) : [],
5723
- ...s.settings ? { settings: { ...s.settings } } : {}
5724
- };
5725
- });
6097
+ /**
6098
+ * Convert PipelineStepInput[] → PipelineDefaultStep[] by enriching from
6099
+ * StepDefinitions. This is the LIVE per-camera dispatch path — it runs
6100
+ * once per decoded frame (up to detectionFps × N cameras/sec) — so it
6101
+ * must NEVER throw. A thin adapter over the pure {@link resolveInputSteps}
6102
+ * seam: an unknown `addonId` (a bad step config sent by a camera
6103
+ * pipeline) is dropped from the tree and logged once (deduped, not
6104
+ * per-frame) at warn level instead of propagating as a per-frame error
6105
+ * loop. A runtime model substitution (the step's explicit model lacks a
6106
+ * build for this node's engine format, so the format default runs
6107
+ * instead) is logged once (deduped) at info level so an operator can see
6108
+ * a camera×node model swap instead of it happening silently. Does NOT
6109
+ * write `this.modelSubstitutions` that map is owned exclusively by
6110
+ * `resolveStepModels` (see its doc comment); a second writer here would
6111
+ * corrupt it.
6112
+ *
6113
+ * `format` defaults to `this.currentEngine.format` (the LIVE per-camera
6114
+ * dispatch path never passes it explicitly). Benchmark call sites with a
6115
+ * per-run engine override (`input.engine`) MUST pass the override's format
6116
+ * explicitly — `this.currentEngine` only gets swapped to the override
6117
+ * LATER in `runPipeline`/`runPipelineBatch`, so relying on the default here
6118
+ * would resolve models against the node's persisted format instead of the
6119
+ * format actually being benchmarked.
6120
+ */
6121
+ inputStepsToPipelineSteps(steps, format = this.currentEngine.format) {
6122
+ const { steps: resolvedSteps, diagnostics } = resolveInputSteps(steps, format);
6123
+ for (const addonId of diagnostics.unknownAddonIds) this.logLiveDispatchIssueOnce(`unknown:${addonId}`, () => this.log.warn("Live pipeline step references an unknown addon — dropping step", { meta: { addonId } }));
6124
+ 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: {
6125
+ step: sub.addonId,
6126
+ chosen: sub.chosen,
6127
+ running: sub.running,
6128
+ format: sub.format
6129
+ } }));
6130
+ return resolvedSteps;
6131
+ }
6132
+ /** Run `fn()` at most once per `key` for the lifetime of this provider instance. */
6133
+ logLiveDispatchIssueOnce(key, fn) {
6134
+ if (this.loggedLiveDispatchIssues.has(key)) return;
6135
+ this.loggedLiveDispatchIssues.add(key);
6136
+ fn();
5726
6137
  }
5727
6138
  /**
5728
6139
  * Single-flight gate around `ensureModelsForSteps`. Concurrent
@@ -5928,7 +6339,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5928
6339
  }
5929
6340
  async runPipelineBatchImpl(input) {
5930
6341
  if (input.frames.length === 0) return { results: [] };
5931
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps);
6342
+ const resolveFormat = input.engine?.format ?? this.currentEngine.format;
6343
+ const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat);
5932
6344
  const enabledSteps = flattenEnabledVideoSteps(benchmarkSteps);
5933
6345
  if (enabledSteps.length === 0) throw new Error("runPipelineBatch: no enabled steps");
5934
6346
  const rootStep = enabledSteps[0];
@@ -6620,7 +7032,7 @@ function buildSchemaSlots(format, modelsDir, customByStep) {
6620
7032
  const slotMap = /* @__PURE__ */ new Map();
6621
7033
  for (const pipelineStep of ALL_PIPELINE_STEPS) {
6622
7034
  const step = pipelineStep.definition;
6623
- const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.formats[format]);
7035
+ const availableModels = mergeCustomModels(step.models, customByStep?.get(step.id) ?? []).filter((m) => m.formats[format] && m.legacy !== true);
6624
7036
  if (availableModels.length === 0) continue;
6625
7037
  const slot = step.slot;
6626
7038
  if (!slotMap.has(slot)) slotMap.set(slot, []);
@@ -6638,7 +7050,9 @@ function buildSchemaSlots(format, modelsDir, customByStep) {
6638
7050
  formats: Object.fromEntries(Object.entries(m.formats).map(([f, entry]) => [f, {
6639
7051
  downloaded: require_model_download_service_C_IHWnXx.isModelDownloaded(modelsDir, m, f),
6640
7052
  sizeMB: entry.sizeMB
6641
- }]))
7053
+ }])),
7054
+ ...m.group ? { group: m.group } : {},
7055
+ ...m.legacy !== void 0 ? { legacy: m.legacy } : {}
6642
7056
  })),
6643
7057
  defaultModelId: step.defaultModelId,
6644
7058
  ...step.defaultModelIdByFormat ? { defaultModelIdByFormat: { ...step.defaultModelIdByFormat } } : {},
@@ -6727,23 +7141,10 @@ function buildDefaultStepTree(format) {
6727
7141
  makeStep("vehicle-classifier", [], { enabled: false }),
6728
7142
  makeStep("segmentation", [], { enabled: false })
6729
7143
  ].filter((s) => s !== null));
6730
- const audioEngine = getDefaultModelForFormat("audio-classifier", format) === "apple-soundanalysis" ? {
6731
- runtime: "python",
6732
- backend: "coreml",
6733
- format: "coreml"
6734
- } : {
6735
- runtime: "python",
6736
- backend: "cpu",
6737
- format: "onnx"
6738
- };
6739
7144
  const audioStep = makeStep("audio-classifier", []);
6740
- const audioClassifier = audioStep ? {
6741
- ...audioStep,
6742
- engine: audioEngine
6743
- } : null;
6744
7145
  const steps = [];
6745
7146
  if (root) steps.push(root);
6746
- if (audioClassifier) steps.push(audioClassifier);
7147
+ if (audioStep) steps.push(audioStep);
6747
7148
  return steps;
6748
7149
  }
6749
7150
  function stepsToPipelineConfig(steps, engine) {
@@ -6763,7 +7164,7 @@ function stepsToPipelineConfig(steps, engine) {
6763
7164
  video: videoSteps.map(toNode),
6764
7165
  ...audioStep ? {
6765
7166
  audio: toNode(audioStep),
6766
- audioEngine: audioStep.engine ?? engine
7167
+ audioEngine: engine
6767
7168
  } : {}
6768
7169
  };
6769
7170
  }
@@ -6895,7 +7296,8 @@ var DEFAULT_CONFIG = {
6895
7296
  engineBackend: "auto",
6896
7297
  engineDevice: "cpu",
6897
7298
  probedBestEngine: "",
6898
- activeEngine: ""
7299
+ activeEngine: "",
7300
+ activeModelSubstitutions: ""
6899
7301
  };
6900
7302
  /** Derive the model-format from a backend value. Called by the provider. */
6901
7303
  function backendToFormat(backend) {
@@ -7016,6 +7418,18 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7016
7418
  readonlyField: true,
7017
7419
  default: ""
7018
7420
  }),
7421
+ this.field({
7422
+ type: "text",
7423
+ key: "activeModelSubstitutions",
7424
+ label: "Model substitutions",
7425
+ 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.",
7426
+ readonlyField: true,
7427
+ default: "",
7428
+ showWhen: {
7429
+ field: "activeModelSubstitutions",
7430
+ notEquals: ""
7431
+ }
7432
+ }),
7019
7433
  this.field({
7020
7434
  type: "select",
7021
7435
  key: "engineBackend",
@@ -7185,12 +7599,15 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7185
7599
  const device = deviceOptions.find((d) => d.value === storedDevice)?.value ?? defaultDeviceFor(backend);
7186
7600
  const prov = targetNode === this.localNodeId() ? this.provider?.getEngineProvisioning() : void 0;
7187
7601
  const activeEngine = prov && prov.runtimeId ? `${prov.runtimeId}/${prov.device ?? "default"} (${prov.state})` : "";
7602
+ const substitutions = this.provider?.getActiveModelSubstitutions() ?? [];
7603
+ const activeModelSubstitutions = substitutions.length === 0 ? "" : substitutions.map((s) => `${s.addonId}: ${s.chosen}→${s.running}`).join(", ");
7188
7604
  const displayBackend = isAuto ? "auto" : backend;
7189
7605
  const raw = {
7190
7606
  ...merged,
7191
7607
  engineBackend: displayBackend,
7192
7608
  engineDevice: device,
7193
- activeEngine
7609
+ activeEngine,
7610
+ activeModelSubstitutions
7194
7611
  };
7195
7612
  const schema = this.globalSettingsSchema();
7196
7613
  if (!schema) return { sections: [] };
@@ -7454,7 +7871,7 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7454
7871
  }).catch(() => {});
7455
7872
  }
7456
7873
  getModelCatalog() {
7457
- return ALL_STEPS.flatMap((s) => [...s.models]);
7874
+ return ALL_STEPS.flatMap((s) => s.models.filter((m) => m.legacy !== true));
7458
7875
  }
7459
7876
  /**
7460
7877
  * Proactively install the OpenVINO Python package when Intel hardware