Validate model paths in the UI

This commit is contained in:
Nicolas Mowen
2026-09-29 16:06:42 -06:00
parent f4d2b3c045
commit 267aa60001
3 changed files with 89 additions and 3 deletions
@@ -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"] },
+2 -1
View File
@@ -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."
}
}
@@ -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<string, string>();
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;
}