@camstack/addon-pipeline 1.1.42 → 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 +535 -115
  5. package/dist/detection-pipeline/index.mjs +529 -110
  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 +78 -31
  14. package/dist/recorder/index.mjs +75 -29
  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 +118 -12
  18. package/dist/session-decode/decode-worker-child.mjs +115 -12
  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 +840 -196
  25. package/dist/stream-broker/index.mjs +831 -188
  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 = {
@@ -3935,12 +4007,15 @@ var PipelineExecutor = class {
3935
4007
  try {
3936
4008
  await this.executeChildren(rootStep.children, mutable, fullFrameJpegProvider, imageWidth, imageHeight, traceBuilder, stepTimings, ctx, poolAgg);
3937
4009
  } catch (err) {
3938
- this.opts.logger?.warn("Pipeline child execution failed — keeping parent detection", { meta: {
3939
- rootStepId: rootStep.stepId,
3940
- parentClass: mutable.macroClass,
3941
- parentScore: mutable.score,
3942
- error: require_dist.errMsg(err)
3943
- } });
4010
+ this.opts.logger?.warn("Pipeline child execution failed — keeping parent detection", {
4011
+ tags: { deviceId },
4012
+ meta: {
4013
+ rootStepId: rootStep.stepId,
4014
+ parentClass: mutable.macroClass,
4015
+ parentScore: mutable.score,
4016
+ error: require_dist.errMsg(err)
4017
+ }
4018
+ });
3944
4019
  }
3945
4020
  firstLevel.push(mutable);
3946
4021
  }
@@ -4205,6 +4280,147 @@ function mergeCustomModels(staticModels, customModels) {
4205
4280
  return merged;
4206
4281
  }
4207
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
4208
4424
  //#region src/detection-pipeline/provider.ts
4209
4425
  /**
4210
4426
  * DetectionPipelineProvider — implements IPipelineExecutorProvider.
@@ -4501,6 +4717,15 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4501
4717
  */
4502
4718
  lastStepsSignature = null;
4503
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
+ /**
4504
4729
  * CB5 shm passthrough — the process-local, LONG-LIVED reader cache for
4505
4730
  * `runPipeline({ frameHandle })`. Opens each named shm segment ONCE and
4506
4731
  * reuses the mapping for every later handle on the same ring (never
@@ -4529,6 +4754,17 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4529
4754
  */
4530
4755
  provisioner = null;
4531
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
+ /**
4532
4768
  * True when `init()` found no persisted engine choice (first boot).
4533
4769
  * `setApi()` reads this flag and auto-picks the best supported runtime
4534
4770
  * using probe data (hardware + bestScore hint), persists the selection,
@@ -4775,7 +5011,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4775
5011
  const stored = await this.loadEngine();
4776
5012
  if (stored) this.currentEngine = stored;
4777
5013
  else await this.resolveAutoEngine();
4778
- 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) {
4779
5016
  this.log.info("engine selection changed — rebuilding pool in place", { meta: {
4780
5017
  from: `${prev.backend}/${prev.device ?? "default"}`,
4781
5018
  to: `${this.currentEngine.backend}/${this.currentEngine.device ?? "default"}`
@@ -4787,18 +5024,56 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4787
5024
  this.executor = null;
4788
5025
  }
4789
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
+ }
4790
5059
  }
4791
5060
  /**
4792
5061
  * Per-node engine-provisioning snapshot. The cap routes `{ nodeId }` for
4793
5062
  * provider resolution only (the router strips it); each node returns its
4794
- * 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.
4795
5066
  */
4796
5067
  getEngineProvisioning() {
4797
- return this.provisioner?.state ?? {
5068
+ const base = this.provisioner?.state ?? {
4798
5069
  runtimeId: null,
4799
5070
  device: null,
4800
5071
  state: "idle"
4801
5072
  };
5073
+ return this.configIssues.length ? {
5074
+ ...base,
5075
+ configIssues: [...this.configIssues]
5076
+ } : base;
4802
5077
  }
4803
5078
  /**
4804
5079
  * Inference gate. Rejects with the current phase/error unless the engine
@@ -4997,43 +5272,45 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
4997
5272
  return buildDefaultStepTree(engine.format);
4998
5273
  }
4999
5274
  /**
5000
- * Substitute, AT RUNTIME ONLY (never persisted), any step whose configured
5001
- * model lacks a build for that step's active engine format. A GLOBAL pipeline
5002
- * config can pin a model that's valid on the cluster's Intel nodes (e.g. the
5003
- * OpenVINO-only `yolov9t-int8`) but impossible on a CoreML/ONNX node — without
5004
- * this the node crash-loops `Model "X" has no <format> format`. The operator's
5005
- * choice stays in the persisted config; this node just runs the smallest
5006
- * catalog model that DOES have its format. Mirrors the per-node engine
5007
- * fallback in `loadEngine`. Catalog models only a custom model that lacks
5008
- * 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.
5009
5286
  */
5010
- substituteIncompatibleModels(steps) {
5011
- const fix = (step) => {
5012
- const format = step.engine?.format ?? this.currentEngine.format;
5013
- let modelId = step.modelId;
5014
- try {
5015
- const entry = getStepDefinition(step.addonId).models.find((m) => m.id === step.modelId);
5016
- if (entry && !entry.formats[format]) {
5017
- const fallback = getDefaultModelForFormat(step.addonId, format);
5018
- if (fallback !== step.modelId) {
5019
- this.log.info("Step model lacks engine format — substituting format default (runtime)", { meta: {
5020
- step: step.addonId,
5021
- configured: step.modelId,
5022
- substitute: fallback,
5023
- format
5024
- } });
5025
- modelId = fallback;
5026
- }
5027
- }
5028
- } catch {}
5029
- const children = step.children?.length ? step.children.map(fix) : step.children;
5030
- 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 : {
5031
5305
  ...step,
5032
- modelId,
5306
+ modelId: resolved,
5033
5307
  ...children ? { children } : {}
5034
5308
  };
5035
- };
5036
- return steps.map(fix);
5309
+ });
5310
+ }
5311
+ modelSubstitutions = /* @__PURE__ */ new Map();
5312
+ getActiveModelSubstitutions() {
5313
+ return [...this.modelSubstitutions.values()];
5037
5314
  }
5038
5315
  async getGlobalSteps() {
5039
5316
  if (this.currentSteps) return this.currentSteps;
@@ -5041,7 +5318,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5041
5318
  if (!raw) {
5042
5319
  const defaults = buildDefaultStepTree(this.currentEngine.format);
5043
5320
  if (defaults.length === 0) return null;
5044
- this.currentSteps = this.substituteIncompatibleModels(defaults);
5321
+ this.currentSteps = this.resolveStepModels(defaults);
5045
5322
  this.writeStore({ [KEY_STEPS]: JSON.stringify(defaults) });
5046
5323
  this.log.info("Bootstrapped default pipeline — object-detection + face + plate recognition enabled by default", { meta: { rootSteps: defaults.length } });
5047
5324
  return this.currentSteps;
@@ -5054,15 +5331,6 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5054
5331
  const audioDef = getStepDefinition("audio-classifier");
5055
5332
  if (audioDef.models.some((m) => m.formats[format])) {
5056
5333
  const modelId = getDefaultModelForFormat("audio-classifier", format);
5057
- const audioEngine = modelId === "apple-soundanalysis" ? {
5058
- runtime: "python",
5059
- backend: "coreml",
5060
- format: "coreml"
5061
- } : {
5062
- runtime: "python",
5063
- backend: "cpu",
5064
- format: "onnx"
5065
- };
5066
5334
  const audioStep = {
5067
5335
  addonId: audioDef.id,
5068
5336
  addonName: audioDef.name,
@@ -5071,15 +5339,14 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5071
5339
  outputClasses: audioDef.outputClasses,
5072
5340
  enabled: true,
5073
5341
  modelId,
5074
- children: [],
5075
- engine: audioEngine
5342
+ children: []
5076
5343
  };
5077
5344
  steps = [...steps, audioStep];
5078
5345
  this.writeStore({ [KEY_STEPS]: JSON.stringify(steps) });
5079
5346
  this.log.info("Migration: added audio-classifier step to persisted pipeline config");
5080
5347
  }
5081
5348
  }
5082
- this.currentSteps = this.substituteIncompatibleModels(steps);
5349
+ this.currentSteps = this.resolveStepModels(steps);
5083
5350
  return this.currentSteps;
5084
5351
  } catch {
5085
5352
  throw new Error(`Failed to parse persisted pipeline steps: corrupt data in key "${KEY_STEPS}"`);
@@ -5089,6 +5356,128 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5089
5356
  if (!this.currentSteps) return null;
5090
5357
  return stepsToPipelineConfig(this.currentSteps, this.currentEngine);
5091
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
+ }
5092
5481
  async getSelectedEngine() {
5093
5482
  return this.currentEngine;
5094
5483
  }
@@ -5101,7 +5490,7 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5101
5490
  const addons = ALL_PIPELINE_STEPS.map((s) => ({
5102
5491
  id: s.definition.id,
5103
5492
  name: s.definition.name,
5104
- models: s.definition.models.map((m) => {
5493
+ models: s.definition.models.filter((m) => m.legacy !== true).map((m) => {
5105
5494
  const formats = {};
5106
5495
  for (const [formatKey, entry] of Object.entries(m.formats)) {
5107
5496
  if (!entry) continue;
@@ -5415,7 +5804,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5415
5804
  emit(`Image: ${imageWidth}×${imageHeight}`);
5416
5805
  }
5417
5806
  decodeMs = performance.now() - decodeT0;
5418
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps);
5807
+ const resolveFormat = input.engine?.format ?? this.currentEngine.format;
5808
+ const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat);
5419
5809
  const stepsSignature = JSON.stringify(benchmarkSteps.map((s) => ({
5420
5810
  id: s.addonId,
5421
5811
  settings: s.settings ?? {}
@@ -5704,22 +6094,46 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5704
6094
  this.log.warn("Override cache dispose failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
5705
6095
  }
5706
6096
  }
5707
- /** Convert PipelineStepInput[] → PipelineDefaultStep[] by enriching from StepDefinitions */
5708
- inputStepsToPipelineSteps(steps) {
5709
- return steps.map((s) => {
5710
- const def = getStepDefinition(s.addonId);
5711
- return {
5712
- addonId: s.addonId,
5713
- addonName: def.name,
5714
- slot: def.slot,
5715
- inputClasses: def.inputClasses ?? [],
5716
- outputClasses: def.outputClasses,
5717
- enabled: s.enabled,
5718
- modelId: s.modelId,
5719
- children: s.children ? this.inputStepsToPipelineSteps(s.children) : [],
5720
- ...s.settings ? { settings: { ...s.settings } } : {}
5721
- };
5722
- });
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();
5723
6137
  }
5724
6138
  /**
5725
6139
  * Single-flight gate around `ensureModelsForSteps`. Concurrent
@@ -5925,7 +6339,8 @@ var DetectionPipelineProvider = class DetectionPipelineProvider {
5925
6339
  }
5926
6340
  async runPipelineBatchImpl(input) {
5927
6341
  if (input.frames.length === 0) return { results: [] };
5928
- const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps);
6342
+ const resolveFormat = input.engine?.format ?? this.currentEngine.format;
6343
+ const benchmarkSteps = this.inputStepsToPipelineSteps(input.steps, resolveFormat);
5929
6344
  const enabledSteps = flattenEnabledVideoSteps(benchmarkSteps);
5930
6345
  if (enabledSteps.length === 0) throw new Error("runPipelineBatch: no enabled steps");
5931
6346
  const rootStep = enabledSteps[0];
@@ -6617,7 +7032,7 @@ function buildSchemaSlots(format, modelsDir, customByStep) {
6617
7032
  const slotMap = /* @__PURE__ */ new Map();
6618
7033
  for (const pipelineStep of ALL_PIPELINE_STEPS) {
6619
7034
  const step = pipelineStep.definition;
6620
- 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);
6621
7036
  if (availableModels.length === 0) continue;
6622
7037
  const slot = step.slot;
6623
7038
  if (!slotMap.has(slot)) slotMap.set(slot, []);
@@ -6635,7 +7050,9 @@ function buildSchemaSlots(format, modelsDir, customByStep) {
6635
7050
  formats: Object.fromEntries(Object.entries(m.formats).map(([f, entry]) => [f, {
6636
7051
  downloaded: require_model_download_service_C_IHWnXx.isModelDownloaded(modelsDir, m, f),
6637
7052
  sizeMB: entry.sizeMB
6638
- }]))
7053
+ }])),
7054
+ ...m.group ? { group: m.group } : {},
7055
+ ...m.legacy !== void 0 ? { legacy: m.legacy } : {}
6639
7056
  })),
6640
7057
  defaultModelId: step.defaultModelId,
6641
7058
  ...step.defaultModelIdByFormat ? { defaultModelIdByFormat: { ...step.defaultModelIdByFormat } } : {},
@@ -6724,23 +7141,10 @@ function buildDefaultStepTree(format) {
6724
7141
  makeStep("vehicle-classifier", [], { enabled: false }),
6725
7142
  makeStep("segmentation", [], { enabled: false })
6726
7143
  ].filter((s) => s !== null));
6727
- const audioEngine = getDefaultModelForFormat("audio-classifier", format) === "apple-soundanalysis" ? {
6728
- runtime: "python",
6729
- backend: "coreml",
6730
- format: "coreml"
6731
- } : {
6732
- runtime: "python",
6733
- backend: "cpu",
6734
- format: "onnx"
6735
- };
6736
7144
  const audioStep = makeStep("audio-classifier", []);
6737
- const audioClassifier = audioStep ? {
6738
- ...audioStep,
6739
- engine: audioEngine
6740
- } : null;
6741
7145
  const steps = [];
6742
7146
  if (root) steps.push(root);
6743
- if (audioClassifier) steps.push(audioClassifier);
7147
+ if (audioStep) steps.push(audioStep);
6744
7148
  return steps;
6745
7149
  }
6746
7150
  function stepsToPipelineConfig(steps, engine) {
@@ -6760,7 +7164,7 @@ function stepsToPipelineConfig(steps, engine) {
6760
7164
  video: videoSteps.map(toNode),
6761
7165
  ...audioStep ? {
6762
7166
  audio: toNode(audioStep),
6763
- audioEngine: audioStep.engine ?? engine
7167
+ audioEngine: engine
6764
7168
  } : {}
6765
7169
  };
6766
7170
  }
@@ -6892,7 +7296,8 @@ var DEFAULT_CONFIG = {
6892
7296
  engineBackend: "auto",
6893
7297
  engineDevice: "cpu",
6894
7298
  probedBestEngine: "",
6895
- activeEngine: ""
7299
+ activeEngine: "",
7300
+ activeModelSubstitutions: ""
6896
7301
  };
6897
7302
  /** Derive the model-format from a backend value. Called by the provider. */
6898
7303
  function backendToFormat(backend) {
@@ -7013,6 +7418,18 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7013
7418
  readonlyField: true,
7014
7419
  default: ""
7015
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
+ }),
7016
7433
  this.field({
7017
7434
  type: "select",
7018
7435
  key: "engineBackend",
@@ -7182,12 +7599,15 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7182
7599
  const device = deviceOptions.find((d) => d.value === storedDevice)?.value ?? defaultDeviceFor(backend);
7183
7600
  const prov = targetNode === this.localNodeId() ? this.provider?.getEngineProvisioning() : void 0;
7184
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(", ");
7185
7604
  const displayBackend = isAuto ? "auto" : backend;
7186
7605
  const raw = {
7187
7606
  ...merged,
7188
7607
  engineBackend: displayBackend,
7189
7608
  engineDevice: device,
7190
- activeEngine
7609
+ activeEngine,
7610
+ activeModelSubstitutions
7191
7611
  };
7192
7612
  const schema = this.globalSettingsSchema();
7193
7613
  if (!schema) return { sections: [] };
@@ -7451,7 +7871,7 @@ var DetectionPipelineAddon = class DetectionPipelineAddon extends require_dist.B
7451
7871
  }).catch(() => {});
7452
7872
  }
7453
7873
  getModelCatalog() {
7454
- return ALL_STEPS.flatMap((s) => [...s.models]);
7874
+ return ALL_STEPS.flatMap((s) => s.models.filter((m) => m.legacy !== true));
7455
7875
  }
7456
7876
  /**
7457
7877
  * Proactively install the OpenVINO Python package when Intel hardware