Compare commits

..

No commits in common. "ce87b9100ae5715384a0adab8896445aeef1092b" and "e7a5f76f26447bb62f252ccd476230f244900420" have entirely different histories.

15 changed files with 62 additions and 235 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 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.
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.
## Edge TPU Detector

View File

@ -192,7 +192,7 @@ cameras:
### Step 4: Configure detectors
By default, Frigate will use a single OpenVINO detector running on the CPU.
By default, Frigate will use a single CPU detector.
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,7 +41,8 @@ class GenAIConfig(FrigateBaseModel):
title="Model",
description="The model to use from the provider for generating descriptions or summaries.",
)
provider: GenAIProviderEnum = Field(
provider: GenAIProviderEnum | None = Field(
default=None,
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": "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."
"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."
},
"lpr": {
"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."
"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."
},
"record": {
"noRecordRole": "No streams have the record role defined. Recording will not function."

View File

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

View File

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

View File

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

View File

@ -1,13 +1,5 @@
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",
@ -34,7 +26,6 @@ const objects: SectionConfigOverrides = {
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
hideAttributeFilters,
],
advancedFields: ["genai"],
uiSchema: {
@ -108,7 +99,6 @@ const objects: SectionConfigOverrides = {
"filters.mask",
"filters.raw_mask",
"genai.required_zones",
hideAttributeFilters,
],
},
camera: {
@ -133,7 +123,6 @@ const objects: SectionConfigOverrides = {
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
hideAttributeFilters,
],
advancedFields: [],
},

View File

@ -59,11 +59,7 @@ import {
} from "@/components/ui/alert-dialog";
import { applySchemaDefaults } from "@/lib/config-schema";
import { cn } from "@/lib/utils";
import {
ConfigSectionData,
HiddenFieldEntry,
JsonValue,
} from "@/types/configForm";
import { ConfigSectionData, JsonValue } from "@/types/configForm";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
@ -73,7 +69,6 @@ import {
buildConfigDataForPath,
flattenOverrides,
getBaseCameraSectionValue,
resolveHiddenFieldEntries,
sanitizeSectionData as sharedSanitizeSectionData,
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
} from "@/utils/configUtil";
@ -96,7 +91,7 @@ export interface SectionConfig {
/** Fields to group together */
fieldGroups?: Record<string, string[]>;
/** Fields to hide from UI */
hiddenFields?: HiddenFieldEntry[];
hiddenFields?: string[];
/** Fields to show in advanced section */
advancedFields?: string[];
/** Fields to compare for override detection */
@ -380,17 +375,12 @@ 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 base;
return sectionConfig.hiddenFields;
}
const base = sectionConfig.hiddenFields ?? [];
return [...new Set([...base, ...sectionConfig.restartRequired])];
}, [
profileName,
sectionConfig.hiddenFields,
sectionConfig.restartRequired,
config,
]);
}, [profileName, sectionConfig.hiddenFields, sectionConfig.restartRequired]);
const sanitizeSectionData = useCallback(
(data: ConfigSectionData) =>
@ -521,7 +511,11 @@ export function ConfigSection({
setPendingOverrides(undefined);
return;
}
const sanitizedData = sanitizeSectionData(data as ConfigSectionData);
const sanitizedData = synthesizeMissingObjectFilters(
sectionPath,
sanitizeSectionData(data as ConfigSectionData),
modifiedSchema ?? undefined,
) as ConfigSectionData;
const nextBaselineFormData = baselineSnapshot;
const overrides = buildOverrides(
sanitizedData,
@ -561,6 +555,8 @@ 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 { getEffectiveHiddenFields } from "@/utils/configUtil";
import { getSectionConfig } from "@/utils/configUtil";
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
detect: "cameraDetect",
@ -247,11 +247,8 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
const rawEntries = useCamerasOverridingSection(config, sectionPath);
const entries = useMemo(() => {
const hiddenFields = getEffectiveHiddenFields(
sectionPath,
"global",
config,
);
const hiddenFields =
getSectionConfig(sectionPath, "global").hiddenFields ?? [];
if (hiddenFields.length === 0) return rawEntries;
return rawEntries
.map((entry) => ({
@ -264,7 +261,7 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
),
}))
.filter((entry) => entry.fieldDeltas.length > 0);
}, [rawEntries, sectionPath, config]);
}, [rawEntries, sectionPath]);
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", "genai"] as const;
const SPECIAL_CASE_SECTIONS = ["motion", "detectors"] as const;
/**
* Check if a section requires special case handling.
@ -53,29 +53,6 @@ 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;
}
@ -132,7 +109,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.
* merges or live track edits.
*/
export function synthesizeMissingObjectFilters(
sectionPath: string,

View File

@ -1,16 +1,12 @@
// 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"];
@ -38,36 +34,6 @@ 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;
@ -130,7 +96,6 @@ export function useConfigOverride({
sectionPath,
compareFields,
}: UseConfigOverrideOptions) {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!config) {
return {
@ -188,29 +153,15 @@ export function useConfigOverride({
sectionPath,
);
// 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 normalizedGlobalValue = normalizeConfigValue(globalValue);
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(collapsedGlobal, compareFields)
: collapsedGlobal;
? pickFields(normalizedGlobalValue, compareFields)
: normalizedGlobalValue;
const comparisonCamera = compareFields
? pickFields(collapsedCamera, compareFields)
: collapsedCamera;
? pickFields(normalizedCameraValue, compareFields)
: normalizedCameraValue;
// Check if the entire section is overridden
const isOverridden = compareFields
@ -225,10 +176,7 @@ export function useConfigOverride({
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
return {
isOverridden: !isEqual(
collapseEmpty(globalFieldValue as JsonValue),
collapseEmpty(cameraFieldValue as JsonValue),
),
isOverridden: !isEqual(globalFieldValue, cameraFieldValue),
globalValue: globalFieldValue,
cameraValue: cameraFieldValue,
};
@ -251,7 +199,7 @@ export function useConfigOverride({
getFieldOverride,
resetToGlobal,
};
}, [config, cameraName, sectionPath, compareFields, schema]);
}, [config, cameraName, sectionPath, compareFields]);
}
/**
@ -304,7 +252,6 @@ export function useAllCameraOverrides(
config: FrigateConfig | undefined,
cameraName: string | undefined,
) {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!config || !cameraName) {
return [];
@ -318,24 +265,17 @@ export function useAllCameraOverrides(
const overriddenSections: string[] = [];
for (const { key, compareFields } of OVERRIDABLE_SECTIONS) {
const globalValue = getEffectiveGlobalBaseline(
config,
key,
compareFields,
schema,
);
const globalValue = normalizeConfigValue(get(config, key));
const cameraValue = normalizeConfigValue(
getBaseCameraSectionValue(config, cameraName, key),
);
const collapsedGlobal = collapseEmpty(globalValue);
const collapsedCamera = collapseEmpty(cameraValue);
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
: collapsedGlobal;
? pickFields(globalValue, compareFields)
: globalValue;
const comparisonCamera = compareFields
? pickFields(collapsedCamera, compareFields)
: collapsedCamera;
? pickFields(cameraValue, compareFields)
: cameraValue;
if (
compareFields && compareFields.length === 0
@ -347,7 +287,7 @@ export function useAllCameraOverrides(
}
return overriddenSections;
}, [config, cameraName, schema]);
}, [config, cameraName]);
}
export interface FieldDelta {
@ -446,40 +386,14 @@ function isPathAllowed(path: string, compareFields?: string[]): boolean {
}
/**
* 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.
* 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.
*/
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[],
@ -547,7 +461,6 @@ export function useCamerasOverridingSection(
config: FrigateConfig | undefined,
sectionPath: string,
): CameraOverrideEntry[] {
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
return useMemo(() => {
if (!config?.cameras || !sectionPath) {
return [];
@ -563,9 +476,11 @@ export function useCamerasOverridingSection(
),
);
const globalValue = collapseEmpty(
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
);
const rawGlobalValue = get(config, sectionPath);
const globalValue: JsonValue =
rawGlobalValue == null
? deriveSyntheticGlobalValue(cameraSectionValues, compareFields)
: normalizeConfigValue(rawGlobalValue);
const entries: CameraOverrideEntry[] = [];
for (let idx = 0; idx < cameraNames.length; idx += 1) {
@ -574,7 +489,7 @@ export function useCamerasOverridingSection(
const deltasByPath = new Map<string, FieldDelta>();
// 1. Camera-level overrides (uses base_config when a profile is active)
const cameraValue = collapseEmpty(cameraSectionValues[idx]);
const cameraValue = cameraSectionValues[idx];
for (const delta of collectFieldDeltas(
globalValue,
cameraValue,
@ -621,5 +536,5 @@ export function useCamerasOverridingSection(
}
return entries;
}, [config, sectionPath, schema]);
}, [config, sectionPath]);
}

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
*/
export function extractSectionSchema(
function extractSectionSchema(
schema: RJSFSchema,
sectionPath: string,
level: "global" | "camera",

View File

@ -13,8 +13,6 @@ 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,14 +587,13 @@ 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).
const resolvedHidden = resolveHiddenFieldEntries(
sectionConfig.hiddenFields,
config,
);
const hiddenFieldsForSanitize =
profileInfo.isProfile && sectionConfig.restartRequired?.length
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
: resolvedHidden;
let hiddenFieldsForSanitize = sectionConfig.hiddenFields;
if (profileInfo.isProfile && sectionConfig.restartRequired?.length) {
const base = sectionConfig.hiddenFields ?? [];
hiddenFieldsForSanitize = [
...new Set([...base, ...sectionConfig.restartRequired]),
];
}
// Sanitize raw form data
const rawData = sanitizeSectionData(
@ -703,36 +702,3 @@ 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;
}