Compare commits

...

6 Commits

Author SHA1 Message Date
Josh Hawkins
ce87b9100a make genai provider required and add special case for UI
prevent validation errors from appearing on initial creation of genai provider by setting the first option in the select dropdown as default
2026-05-07 08:47:57 -05:00
Josh Hawkins
6ca60dd23e update docs for new detector default 2026-05-07 08:37:20 -05:00
Josh Hawkins
2d0598ca45 tweaks 2026-05-07 08:27:18 -05:00
Josh Hawkins
bbc79f98c2 siimplify object filters handling
live updating was getting very messy. users will just need to save once they enable a new object in order to see filters for that object
2026-05-06 22:06:40 -05:00
Josh Hawkins
feff214d73 add support for config-aware patterns in section hiddenFields
Section configs can now declare dynamic hidden-field entries as functions of the loaded config; objects.ts uses this to hide auto-populated attribute filters (DHL, face, license_plate, etc.) from the form, save flow, and override popover when those labels aren't user-settable
2026-05-06 21:34:55 -05:00
Josh Hawkins
88755513c8 fix override detection for sections unset in the global config
Override badges and the blue dot now compare against schema defaults for sections like motion that the API serializes as null when omitted from the global YAML, instead of treating any populated camera config as an override
2026-05-06 21:28:50 -05:00
15 changed files with 235 additions and 62 deletions

View File

@ -72,7 +72,7 @@ This does not affect using hardware for accelerating other tasks such as [semant
# Officially Supported Detectors
Frigate provides a number of builtin detector types. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
Frigate provides a number of builtin detector types. By default, Frigate will use a single OpenVINO detector running on the CPU. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
## Edge TPU Detector

View File

@ -192,7 +192,7 @@ cameras:
### Step 4: Configure detectors
By default, Frigate will use a single CPU detector.
By default, Frigate will use a single OpenVINO detector running on the CPU.
In many cases, the integrated graphics on Intel CPUs provides sufficient performance for typical Frigate setups. If you have an Intel processor, you can follow the configuration below.

View File

@ -41,8 +41,7 @@ class GenAIConfig(FrigateBaseModel):
title="Model",
description="The model to use from the provider for generating descriptions or summaries.",
)
provider: GenAIProviderEnum | None = Field(
default=None,
provider: GenAIProviderEnum = Field(
title="Provider",
description="The GenAI provider to use (for example: ollama, gemini, openai).",
)

View File

@ -1663,12 +1663,12 @@
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit."
},
"faceRecognition": {
"globalDisabled": "Face recognition is not enabled at the global level. Enable it in Enrichments for camera-level face recognition to function.",
"personNotTracked": "Face recognition requires the 'person' object to be tracked. Ensure 'person' is in the object tracking list."
"globalDisabled": "The face recognition enrichment must be enabled for face recognition features to function on this camera.",
"personNotTracked": "Face recognition requires the 'person' object to be tracked. Enable 'person' in Objects for this camera."
},
"lpr": {
"globalDisabled": "License plate recognition is not enabled at the global level. Enable it in Enrichments for camera-level LPR to function.",
"vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked."
"globalDisabled": "The license plate recognition enrichment must be enabled for LPR features to function on this camera.",
"vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked. Enable 'car' or 'motorcycle' in Objects for this camera."
},
"record": {
"noRecordRole": "No streams have the record role defined. Recording will not function."

View File

@ -10,7 +10,11 @@ const audioTranscription: SectionConfigOverrides = {
severity: "warning",
condition: (ctx) => {
if (ctx.level === "camera" && ctx.fullCameraConfig) {
return ctx.fullCameraConfig.audio.enabled === false;
return (
!ctx.fullCameraConfig.ffmpeg?.inputs?.some((input) =>
input.roles?.includes("audio"),
) || ctx.fullCameraConfig.audio.enabled === false
);
}
return false;
},

View File

@ -53,6 +53,11 @@ const faceRecognition: SectionConfigOverrides = {
"device",
],
restartRequired: ["enabled", "model_size", "device"],
uiSchema: {
model_size: {
"ui:options": { size: "xs" },
},
},
},
};

View File

@ -83,6 +83,9 @@ const lpr: SectionConfigOverrides = {
suppressDescription: true,
},
},
model_size: {
"ui:options": { size: "xs" },
},
},
},
};

View File

@ -1,5 +1,13 @@
import type { FrigateConfig } from "@/types/frigateConfig";
import type { SectionConfigOverrides } from "./types";
// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon,
// etc.) are populated into objects.filters by the backend even when the
// model can't actually detect them. They aren't user-settable, so hide any
// `filters.<attr>` patterns from forms and override comparisons.
const hideAttributeFilters = (config: FrigateConfig): string[] =>
(config.model?.all_attributes ?? []).map((attr) => `filters.${attr}`);
const objects: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_filters",
@ -26,6 +34,7 @@ const objects: SectionConfigOverrides = {
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
hideAttributeFilters,
],
advancedFields: ["genai"],
uiSchema: {
@ -99,6 +108,7 @@ const objects: SectionConfigOverrides = {
"filters.mask",
"filters.raw_mask",
"genai.required_zones",
hideAttributeFilters,
],
},
camera: {
@ -123,6 +133,7 @@ const objects: SectionConfigOverrides = {
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
hideAttributeFilters,
],
advancedFields: [],
},

View File

@ -59,7 +59,11 @@ import {
} from "@/components/ui/alert-dialog";
import { applySchemaDefaults } from "@/lib/config-schema";
import { cn } from "@/lib/utils";
import { ConfigSectionData, JsonValue } from "@/types/configForm";
import {
ConfigSectionData,
HiddenFieldEntry,
JsonValue,
} from "@/types/configForm";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
@ -69,6 +73,7 @@ import {
buildConfigDataForPath,
flattenOverrides,
getBaseCameraSectionValue,
resolveHiddenFieldEntries,
sanitizeSectionData as sharedSanitizeSectionData,
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
} from "@/utils/configUtil";
@ -91,7 +96,7 @@ export interface SectionConfig {
/** Fields to group together */
fieldGroups?: Record<string, string[]>;
/** Fields to hide from UI */
hiddenFields?: string[];
hiddenFields?: HiddenFieldEntry[];
/** Fields to show in advanced section */
advancedFields?: string[];
/** Fields to compare for override detection */
@ -375,12 +380,17 @@ export function ConfigSection({
// When editing a profile, hide fields that require a restart since they
// cannot take effect via profile switching alone.
const effectiveHiddenFields = useMemo(() => {
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, config);
if (!profileName || !sectionConfig.restartRequired?.length) {
return sectionConfig.hiddenFields;
return base;
}
const base = sectionConfig.hiddenFields ?? [];
return [...new Set([...base, ...sectionConfig.restartRequired])];
}, [profileName, sectionConfig.hiddenFields, sectionConfig.restartRequired]);
}, [
profileName,
sectionConfig.hiddenFields,
sectionConfig.restartRequired,
config,
]);
const sanitizeSectionData = useCallback(
(data: ConfigSectionData) =>
@ -511,11 +521,7 @@ export function ConfigSection({
setPendingOverrides(undefined);
return;
}
const sanitizedData = synthesizeMissingObjectFilters(
sectionPath,
sanitizeSectionData(data as ConfigSectionData),
modifiedSchema ?? undefined,
) as ConfigSectionData;
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
const nextBaselineFormData = baselineSnapshot;
const overrides = buildOverrides(
sanitizedData,
@ -555,8 +561,6 @@ export function ConfigSection({
setPendingOverrides,
setDirtyOverrides,
baselineSnapshot,
sectionPath,
modifiedSchema,
],
);

View File

@ -20,7 +20,7 @@ import type { ProfilesApiResponse } from "@/types/profile";
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { formatList } from "@/utils/stringUtil";
import { getSectionConfig } from "@/utils/configUtil";
import { getEffectiveHiddenFields } from "@/utils/configUtil";
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
detect: "cameraDetect",
@ -247,8 +247,11 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
const rawEntries = useCamerasOverridingSection(config, sectionPath);
const entries = useMemo(() => {
const hiddenFields =
getSectionConfig(sectionPath, "global").hiddenFields ?? [];
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"global",
config,
);
if (hiddenFields.length === 0) return rawEntries;
return rawEntries
.map((entry) => ({
@ -261,7 +264,7 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
),
}))
.filter((entry) => entry.fieldDeltas.length > 0);
}, [rawEntries, sectionPath]);
}, [rawEntries, sectionPath, config]);
if (SECTIONS_WITHOUT_OVERRIDE_BADGE.has(sectionPath)) {
return null;

View File

@ -15,7 +15,7 @@ import { JsonObject, JsonValue } from "@/types/configForm";
* Sections that require special handling at the global level.
* Add new section paths here as needed.
*/
const SPECIAL_CASE_SECTIONS = ["motion", "detectors"] as const;
const SPECIAL_CASE_SECTIONS = ["motion", "detectors", "genai"] as const;
/**
* Check if a section requires special case handling.
@ -53,6 +53,29 @@ export function modifySchemaForSection(
return schemaWithoutDefault;
}
if (sectionPath === "genai") {
const additional = schema.additionalProperties;
if (
additional &&
typeof additional === "object" &&
!Array.isArray(additional)
) {
const props = (additional as RJSFSchema).properties;
if (props && typeof props.provider === "object") {
return {
...schema,
additionalProperties: {
...additional,
properties: {
...props,
provider: { ...(props.provider as object), default: "openai" },
},
},
};
}
}
}
return schema;
}
@ -109,7 +132,7 @@ export function getEffectiveDefaultsForSection(
* Add default filter entries for any label in `objects.track` that isn't
* already in `objects.filters`, so each tracked label gets a collapsible.
* The backend only auto-populates filters at config init, not after profile
* merges or live track edits.
* merges.
*/
export function synthesizeMissingObjectFilters(
sectionPath: string,

View File

@ -1,12 +1,16 @@
// Hook to detect when camera config overrides global defaults
import { useMemo } from "react";
import useSWR from "swr";
import isEqual from "lodash/isEqual";
import get from "lodash/get";
import set from "lodash/set";
import type { RJSFSchema } from "@rjsf/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject, JsonValue } from "@/types/configForm";
import { isJsonObject } from "@/lib/utils";
import { getBaseCameraSectionValue } from "@/utils/configUtil";
import { extractSectionSchema } from "@/hooks/use-config-schema";
import { applySchemaDefaults } from "@/lib/config-schema";
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
@ -34,6 +38,36 @@ export function normalizeConfigValue(value: unknown): JsonValue {
return stripInternalFields(value as JsonValue);
}
/**
* Collapse null and empty-object values for override comparisons so
* semantically equivalent shapes match. The schema may default `mask: None`
* while the runtime camera config carries `mask: {}` both mean "no
* masks", so collapsing them here keeps the equality check honest. We
* keep this off the public `normalizeConfigValue` so save-flow code paths
* (which serialize form data) aren't affected.
*/
function collapseEmpty(value: JsonValue): JsonValue {
if (Array.isArray(value)) {
return value.map(collapseEmpty);
}
if (isJsonObject(value)) {
const cleaned: JsonObject = {};
for (const [key, val] of Object.entries(value as JsonObject)) {
if (val === null || val === undefined) continue;
const collapsed = collapseEmpty(val as JsonValue);
if (
isJsonObject(collapsed) &&
Object.keys(collapsed as JsonObject).length === 0
) {
continue;
}
cleaned[key] = collapsed;
}
return cleaned;
}
return value;
}
export interface OverrideStatus {
/** Whether the field is overridden from global */
isOverridden: boolean;
@ -96,6 +130,7 @@ export function useConfigOverride({
sectionPath,
compareFields,
}: UseConfigOverrideOptions) {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!config) {
return {
@ -153,15 +188,29 @@ export function useConfigOverride({
sectionPath,
);
const normalizedGlobalValue = normalizeConfigValue(globalValue);
// Use the effective baseline (schema defaults when the global section
// is unset, e.g. motion). Without this, sections omitted from the global
// YAML would always read as "overridden" because the raw global value is
// null while every camera has populated defaults.
const normalizedGlobalValue = getEffectiveGlobalBaseline(
config,
sectionPath,
compareFields,
schema,
);
const normalizedCameraValue = normalizeConfigValue(cameraValue);
// Collapse empty/null values for comparison so semantically equivalent
// shapes (e.g. schema default `mask: null` vs runtime `mask: {}`) match.
const collapsedGlobal = collapseEmpty(normalizedGlobalValue);
const collapsedCamera = collapseEmpty(normalizedCameraValue);
const comparisonGlobal = compareFields
? pickFields(normalizedGlobalValue, compareFields)
: normalizedGlobalValue;
? pickFields(collapsedGlobal, compareFields)
: collapsedGlobal;
const comparisonCamera = compareFields
? pickFields(normalizedCameraValue, compareFields)
: normalizedCameraValue;
? pickFields(collapsedCamera, compareFields)
: collapsedCamera;
// Check if the entire section is overridden
const isOverridden = compareFields
@ -176,7 +225,10 @@ export function useConfigOverride({
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
return {
isOverridden: !isEqual(globalFieldValue, cameraFieldValue),
isOverridden: !isEqual(
collapseEmpty(globalFieldValue as JsonValue),
collapseEmpty(cameraFieldValue as JsonValue),
),
globalValue: globalFieldValue,
cameraValue: cameraFieldValue,
};
@ -199,7 +251,7 @@ export function useConfigOverride({
getFieldOverride,
resetToGlobal,
};
}, [config, cameraName, sectionPath, compareFields]);
}, [config, cameraName, sectionPath, compareFields, schema]);
}
/**
@ -252,6 +304,7 @@ export function useAllCameraOverrides(
config: FrigateConfig | undefined,
cameraName: string | undefined,
) {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!config || !cameraName) {
return [];
@ -265,17 +318,24 @@ export function useAllCameraOverrides(
const overriddenSections: string[] = [];
for (const { key, compareFields } of OVERRIDABLE_SECTIONS) {
const globalValue = normalizeConfigValue(get(config, key));
const globalValue = getEffectiveGlobalBaseline(
config,
key,
compareFields,
schema,
);
const cameraValue = normalizeConfigValue(
getBaseCameraSectionValue(config, cameraName, key),
);
const collapsedGlobal = collapseEmpty(globalValue);
const collapsedCamera = collapseEmpty(cameraValue);
const comparisonGlobal = compareFields
? pickFields(globalValue, compareFields)
: globalValue;
? pickFields(collapsedGlobal, compareFields)
: collapsedGlobal;
const comparisonCamera = compareFields
? pickFields(cameraValue, compareFields)
: cameraValue;
? pickFields(collapsedCamera, compareFields)
: collapsedCamera;
if (
compareFields && compareFields.length === 0
@ -287,7 +347,7 @@ export function useAllCameraOverrides(
}
return overriddenSections;
}, [config, cameraName]);
}, [config, cameraName, schema]);
}
export interface FieldDelta {
@ -386,14 +446,40 @@ function isPathAllowed(path: string, compareFields?: string[]): boolean {
}
/**
* Some Frigate sections (notably `motion`) are dumped by the backend with
* `exclude_unset=True`, so when the user hasn't explicitly written the section
* in their global YAML the API returns null even though every camera still
* gets defaults applied at runtime. To still detect cross-camera differences
* in those sections we synthesize a baseline by taking the modal (most common)
* value at each leaf path across cameras cameras whose value diverges from
* the modal are treated as overriding.
* Resolve the effective global baseline used for override comparisons.
*
* - When the global section is explicitly set, return it (normalized).
* - Otherwise prefer the camera-level schema defaults so a camera that
* diverges from the implicit Pydantic default registers as overriding
* even with a single camera in the deployment. (Sections like `motion`
* are dumped with `exclude_unset=True`, so the API returns null whenever
* the user hasn't written the section globally.)
* - Fall back to a modal-across-cameras synthetic baseline when the schema
* hasn't loaded yet or the section isn't in it.
*/
function getEffectiveGlobalBaseline(
config: FrigateConfig,
sectionPath: string,
compareFields?: string[],
schema?: RJSFSchema,
): JsonValue {
const rawGlobalValue = get(config, sectionPath);
if (rawGlobalValue != null) {
return normalizeConfigValue(rawGlobalValue);
}
if (schema) {
const sectionSchema = extractSectionSchema(schema, sectionPath, "camera");
if (sectionSchema) {
const defaults = applySchemaDefaults(sectionSchema, {});
return normalizeConfigValue(defaults as JsonValue);
}
}
const cameraSectionValues = Object.keys(config.cameras ?? {}).map((name) =>
normalizeConfigValue(getBaseCameraSectionValue(config, name, sectionPath)),
);
return deriveSyntheticGlobalValue(cameraSectionValues, compareFields);
}
function deriveSyntheticGlobalValue(
cameraSectionValues: JsonValue[],
compareFields?: string[],
@ -461,6 +547,7 @@ export function useCamerasOverridingSection(
config: FrigateConfig | undefined,
sectionPath: string,
): CameraOverrideEntry[] {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!config?.cameras || !sectionPath) {
return [];
@ -476,11 +563,9 @@ export function useCamerasOverridingSection(
),
);
const rawGlobalValue = get(config, sectionPath);
const globalValue: JsonValue =
rawGlobalValue == null
? deriveSyntheticGlobalValue(cameraSectionValues, compareFields)
: normalizeConfigValue(rawGlobalValue);
const globalValue = collapseEmpty(
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
);
const entries: CameraOverrideEntry[] = [];
for (let idx = 0; idx < cameraNames.length; idx += 1) {
@ -489,7 +574,7 @@ export function useCamerasOverridingSection(
const deltasByPath = new Map<string, FieldDelta>();
// 1. Camera-level overrides (uses base_config when a profile is active)
const cameraValue = cameraSectionValues[idx];
const cameraValue = collapseEmpty(cameraSectionValues[idx]);
for (const delta of collectFieldDeltas(
globalValue,
cameraValue,
@ -536,5 +621,5 @@ export function useCamerasOverridingSection(
}
return entries;
}, [config, sectionPath]);
}, [config, sectionPath, schema]);
}

View File

@ -24,7 +24,7 @@ const getSchemaDefinitions = (schema: RJSFSchema): Record<string, RJSFSchema> =>
* Extracts and resolves a section schema from the full config schema
* Uses caching to avoid repeated expensive resolution
*/
function extractSectionSchema(
export function extractSectionSchema(
schema: RJSFSchema,
sectionPath: string,
level: "global" | "camera",

View File

@ -13,6 +13,8 @@ export type JsonArray = JsonValue[];
export type ConfigSectionData = JsonObject;
export type HiddenFieldEntry = string | ((config: FrigateConfig) => string[]);
export type ConfigFormContext = {
level?: "global" | "camera";
cameraName?: string;

View File

@ -587,13 +587,14 @@ export function prepareSectionSavePayload(opts: {
// For profile sections, also hide restart-required fields to match
// effectiveHiddenFields in BaseSection (prevents spurious deletion markers
// for fields that are hidden from the form during profile editing).
let hiddenFieldsForSanitize = sectionConfig.hiddenFields;
if (profileInfo.isProfile && sectionConfig.restartRequired?.length) {
const base = sectionConfig.hiddenFields ?? [];
hiddenFieldsForSanitize = [
...new Set([...base, ...sectionConfig.restartRequired]),
];
}
const resolvedHidden = resolveHiddenFieldEntries(
sectionConfig.hiddenFields,
config,
);
const hiddenFieldsForSanitize =
profileInfo.isProfile && sectionConfig.restartRequired?.length
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
: resolvedHidden;
// Sanitize raw form data
const rawData = sanitizeSectionData(
@ -702,3 +703,36 @@ export function getSectionConfig(
: entry.camera;
return mergeSectionConfig(entry.base, overrides);
}
/**
* Resolve the effective hidden-field patterns for a section. Each entry in
* `hiddenFields` is either a literal pattern or a function that produces
* patterns from the loaded config (e.g. `filters.<attr>` for each
* `model.all_attributes` entry on the objects section).
*/
export function getEffectiveHiddenFields(
sectionKey: string,
level: "global" | "camera" | "replay",
config: FrigateConfig | undefined,
): string[] {
return resolveHiddenFieldEntries(
getSectionConfig(sectionKey, level).hiddenFields,
config,
);
}
export function resolveHiddenFieldEntries(
entries: SectionConfig["hiddenFields"] | undefined,
config: FrigateConfig | undefined,
): string[] {
if (!entries || entries.length === 0) return [];
const result: string[] = [];
for (const entry of entries) {
if (typeof entry === "function") {
if (config) result.push(...entry(config));
} else {
result.push(entry);
}
}
return result;
}