From 267aa60001d323bc9389b4ba4e41e821149496c6 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 29 Sep 2026 16:06:42 -0600 Subject: [PATCH] Validate model paths in the UI --- .../specs/settings/detection-models.spec.ts | 54 +++++++++++++++++++ web/public/locales/en/config/validation.json | 3 +- .../config-form/section-validations/models.ts | 35 +++++++++++- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/web/e2e/specs/settings/detection-models.spec.ts b/web/e2e/specs/settings/detection-models.spec.ts index 629c5ce289..40a3a170db 100644 --- a/web/e2e/specs/settings/detection-models.spec.ts +++ b/web/e2e/specs/settings/detection-models.spec.ts @@ -257,6 +257,60 @@ test.describe("Detection models settings @high", () => { await expect(scene).toBeFocused(); }); + test("one model file cannot be split across scenes", async ({ + frigateApp, + }) => { + await installRoutes(frigateApp.page, [ + { + scene: "default", + devices: ["openvino:GPU.0"], + path: "/config/model_cache/yolo.onnx", + }, + { + scene: "driveway", + devices: ["openvino:GPU.1"], + path: "/config/model_cache/other.onnx", + }, + ]); + await openPage(frigateApp); + + await frigateApp.page + .locator("#root_1_path") + .fill("/config/model_cache/yolo.onnx"); + + await expect(frigateApp.page.locator("#pageRoot")).toContainText( + "The Default and driveway models use the same model file", + ); + await expect( + frigateApp.page.getByRole("button", { name: /^Save$/ }), + ).toBeDisabled(); + }); + + test("one model file may run on different detectors", async ({ + frigateApp, + }) => { + await installRoutes(frigateApp.page, [ + { + scene: "default", + devices: ["openvino:GPU.0"], + path: "/config/model_cache/yolo.onnx", + }, + { + scene: "driveway", + devices: ["onnx"], + path: "/config/model_cache/yolo.onnx", + }, + ]); + await openPage(frigateApp); + + await expect(frigateApp.page.locator("#pageRoot")).toContainText( + "driveway", + ); + await expect(frigateApp.page.locator("#pageRoot")).not.toContainText( + "models use the same model file", + ); + }); + test("two models cannot share a scene", async ({ frigateApp }) => { await installRoutes(frigateApp.page, [ { scene: "default", devices: ["cpu"] }, diff --git a/web/public/locales/en/config/validation.json b/web/public/locales/en/config/validation.json index c341793b3b..2e2ca8b455 100644 --- a/web/public/locales/en/config/validation.json +++ b/web/public/locales/en/config/validation.json @@ -34,6 +34,7 @@ }, "models": { "defaultRequired": "One model must use the default scene. Without it, any camera that does not choose a scene has no model to fall back to.", - "sceneDuplicate": "Each model must use a different scene." + "sceneDuplicate": "Each model must use a different scene.", + "sameModel": "The {{scene}} and {{other}} models use the same model file. Running one model under several scenes only splits the detection work across separate detectors, which is slower than one model with all of the hardware listed under it." } } diff --git a/web/src/components/config-form/section-validations/models.ts b/web/src/components/config-form/section-validations/models.ts index 5b3d85c2ab..8675ad0d76 100644 --- a/web/src/components/config-form/section-validations/models.ts +++ b/web/src/components/config-form/section-validations/models.ts @@ -1,14 +1,17 @@ import type { FormValidation } from "@rjsf/utils"; import type { TFunction } from "i18next"; import { isJsonObject } from "@/lib/utils"; -import { DEFAULT_SCENE } from "@/utils/modelUtil"; +import { DEFAULT_SCENE, getSceneLabel } from "@/utils/modelUtil"; /** * A camera that names no scene runs the model whose scene is `default`. * Without one the backend rejects the config outright once a second model * exists, and with a single model it silently runs every camera on whatever * that model is. Both are surprising, so require the default to be present. - * Scenes are free-form names, so also check that no two models share one. + * Scenes are free-form names, so also check that no two models share one, and + * that one model isn't split across scenes. The backend combines models that + * load the same file on the same detector, so running one model per scene only + * splits the detection work across separate detectors. */ export function validateModelScenes( formData: unknown, @@ -31,5 +34,33 @@ export function validateModelScenes( errors.addError?.(t("models.sceneDuplicate", { ns: "config/validation" })); } + // detector and path -> the scene of the first model using them + const seen = new Map(); + + formData.forEach((model, index) => { + if (!isJsonObject(model) || typeof model.path !== "string" || !model.path) { + return; + } + + const devices = Array.isArray(model.devices) ? model.devices : []; + const detector = + typeof devices[0] === "string" ? devices[0].split(":")[0] : ""; + const key = `${detector}|${model.path}`; + const other = seen.get(key); + + if (other === undefined) { + seen.set(key, scenes[index]); + return; + } + + errors.addError?.( + t("models.sameModel", { + ns: "config/validation", + scene: getSceneLabel(t, other), + other: getSceneLabel(t, scenes[index]), + }), + ); + }); + return errors; }