From dfe365cd28faf18205f8f68a87ef55cc9ef4c090 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:16:19 -0500 Subject: [PATCH] Miscellaneous fixes (#22780) * fix mobile export crash by removing stale iOS non-modal drawer workaround * Remove titlecase to avoid Gemma4 handling plain labels as proper nouns * Improve titling: * Make directions more clear * Properly capitalize delivery services * update dispatcher config reference on save * subscribe to review topic so ReviewDescriptionProcessor knows genai is enabled * auto-send ON genai review WS message when enabled_in_config transitions to true * remove unused object level * update docs to clarify pre/post capture settings * add ui docs links * improve known_plates field in settings UI * only show save all when multiple sections are changed or if the section being changed is not currently being viewed * fix docs --------- Co-authored-by: Nicolas Mowen --- .../object_classification.md | 2 +- docs/docs/configuration/record.md | 70 +++++ frigate/api/app.py | 3 + frigate/const.py | 16 + .../post/review_descriptions.py | 12 +- frigate/embeddings/maintainer.py | 1 + frigate/genai/__init__.py | 5 +- web/public/locales/en/views/settings.json | 4 + .../config-form/section-configs/lpr.ts | 7 + .../config-form/section-configs/record.ts | 10 + .../CameraReviewStatusToggles.tsx | 17 +- .../theme/fields/KnownPlatesField.tsx | 277 ++++++++++++++++++ .../config-form/theme/frigateTheme.ts | 2 + .../overlay/MobileReviewSettingsDrawer.tsx | 3 +- web/src/pages/Settings.tsx | 25 +- 15 files changed, 443 insertions(+), 11 deletions(-) create mode 100644 web/src/components/config-form/theme/fields/KnownPlatesField.tsx diff --git a/docs/docs/configuration/custom_classification/object_classification.md b/docs/docs/configuration/custom_classification/object_classification.md index 6e68d4ba9..4c74bde63 100644 --- a/docs/docs/configuration/custom_classification/object_classification.md +++ b/docs/docs/configuration/custom_classification/object_classification.md @@ -158,7 +158,7 @@ Enable debug logs for classification models by adding `frigate.data_processing.r Navigate to . - Set **Logging level** to `debug` -- Set **Per-process log level > Frigate.Data Processing.Real Time.Custom Classification** to `debug` for verbose classification logging +- Set **Per-process log level > `frigate.data_processing.real_time.custom_classification`** to `debug` for verbose classification logging diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index d98f51491..043a9d0af 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -123,6 +123,76 @@ record: +## Pre-capture and Post-capture + +The `pre_capture` and `post_capture` settings control how many seconds of video are included before and after an alert or detection. These can be configured independently for alerts and detections, and can be set globally or overridden per camera. + + + + +Navigate to for global defaults, or to override for a specific camera. + +| Field | Description | +| ---------------------------------------------- | ---------------------------------------------------- | +| **Alert retention > Pre-capture seconds** | Seconds of video to include before an alert event | +| **Alert retention > Post-capture seconds** | Seconds of video to include after an alert event | +| **Detection retention > Pre-capture seconds** | Seconds of video to include before a detection event | +| **Detection retention > Post-capture seconds** | Seconds of video to include after a detection event | + + + + +```yaml +record: + enabled: True + alerts: + pre_capture: 5 # seconds before the alert to include + post_capture: 5 # seconds after the alert to include + detections: + pre_capture: 5 # seconds before the detection to include + post_capture: 5 # seconds after the detection to include +``` + + + + +- **Default**: 5 seconds for both pre and post capture. +- **Pre-capture maximum**: 60 seconds. +- These settings apply per review category (alerts and detections), not per object type. + +### How pre/post capture interacts with retention mode + +The `pre_capture` and `post_capture` values define the **time window** around a review item, but only recording segments that also match the configured **retention mode** are actually kept on disk. + +- **`mode: all`** — Retains every segment within the capture window, regardless of whether motion was detected. +- **`mode: motion`** (default) — Only retains segments within the capture window that contain motion. This includes segments with active tracked objects, since object motion implies motion. Segments without any motion are discarded even if they fall within the pre/post capture range. +- **`mode: active_objects`** — Only retains segments within the capture window where tracked objects were actively moving. Segments with general motion but no active objects are discarded. + +This means that with the default `motion` mode, you may see less footage than the configured pre/post capture duration if parts of the capture window had no motion. + +To guarantee the full pre/post capture duration is always retained: + +```yaml +record: + enabled: True + alerts: + pre_capture: 10 + post_capture: 10 + retain: + days: 30 + mode: all # retains all segments within the capture window +``` + +:::note + +Because recording segments are written in 10 second chunks, pre-capture timing depends on segment boundaries. The actual pre-capture footage may be slightly shorter or longer than the exact configured value. + +::: + +### Where to view pre/post capture footage + +Pre and post capture footage is included in the **recording timeline**, visible in the History view. Note that pre/post capture settings only affect which recording segments are **retained on disk** — they do not change the start and end points shown in the UI. The History view will still center on the review item's actual time range, but you can scrub backward and forward through the retained pre/post capture footage on the timeline. The Explore view shows object-specific clips that are trimmed to when the tracked object was actually visible, so pre/post capture time will not be reflected there. + ## Will Frigate delete old recordings if my storage runs out? As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted. diff --git a/frigate/api/app.py b/frigate/api/app.py index af6778451..57d1f0a79 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -694,6 +694,9 @@ def config_set(request: Request, body: AppConfigSetBody): if request.app.stats_emitter is not None: request.app.stats_emitter.config = config + if request.app.dispatcher is not None: + request.app.dispatcher.config = config + if body.update_topic: if body.update_topic.startswith("config/cameras/"): _, _, camera, field = body.update_topic.split("/") diff --git a/frigate/const.py b/frigate/const.py index 6b1e227d5..51e06e4ad 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -44,6 +44,22 @@ DEFAULT_ATTRIBUTE_LABEL_MAP = { ], "motorcycle": ["license_plate"], } +ATTRIBUTE_LABEL_DISPLAY_MAP = { + "amazon": "Amazon", + "an_post": "An Post", + "canada_post": "Canada Post", + "dhl": "DHL", + "dpd": "DPD", + "fedex": "FedEx", + "gls": "GLS", + "nzpost": "NZ Post", + "postnl": "PostNL", + "postnord": "PostNord", + "purolator": "Purolator", + "royal_mail": "Royal Mail", + "ups": "UPS", + "usps": "USPS", +} LABEL_CONSOLIDATION_MAP = { "car": 0.8, "face": 0.5, diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index 4bb2deac2..536b57f3c 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -19,7 +19,12 @@ from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig from frigate.config.camera import CameraConfig from frigate.config.camera.review import GenAIReviewConfig, ImageSourceEnum -from frigate.const import CACHE_DIR, CLIPS_DIR, UPDATE_REVIEW_DESCRIPTION +from frigate.const import ( + ATTRIBUTE_LABEL_DISPLAY_MAP, + CACHE_DIR, + CLIPS_DIR, + UPDATE_REVIEW_DESCRIPTION, +) from frigate.data_processing.types import PostProcessDataEnum from frigate.genai import GenAIClient from frigate.genai.manager import GenAIClientManager @@ -556,10 +561,11 @@ def run_analysis( if "-verified" in label: continue elif label in labelmap_objects: - object_type = titlecase(label.replace("_", " ")) + object_type = label.replace("_", " ") if label in attribute_labels: - unified_objects.append(f"{object_type} (delivery/service)") + display_name = ATTRIBUTE_LABEL_DISPLAY_MAP.get(label, object_type) + unified_objects.append(f"{display_name} (delivery/service)") else: unified_objects.append(object_type) diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 9247f4fb4..3f066a860 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -92,6 +92,7 @@ class EmbeddingMaintainer(threading.Thread): CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove, CameraConfigUpdateEnum.object_genai, + CameraConfigUpdateEnum.review, CameraConfigUpdateEnum.review_genai, CameraConfigUpdateEnum.semantic_search, ], diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index d80e53b2e..d95dd2cae 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -106,8 +106,8 @@ When forming your description: ## Response Field Guidelines Respond with a JSON object matching the provided schema. Field-specific guidance: -- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. Always use subject names from "Objects in Scene" — do not replace named subjects with generic terms like "a person" or "the individual". Your description should align with and support the threat level you assign. -- `title`: Characterize **what took place and where** — interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). Always include subject names from "Objects in Scene" — do not replace named subjects with generic terms. No editorial qualifiers like "routine" or "suspicious." +- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign. +- `title`: Characterize **what took place and where** — interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). For named subjects, always use their name. For unnamed objects, refer to them naturally with articles. No editorial qualifiers like "routine" or "suspicious." - `potential_threat_level`: Must be consistent with your scene description and the activity patterns above. {get_concern_prompt()} @@ -190,6 +190,7 @@ Each line represents a detection state, not necessarily unique individuals. The if any("←" in obj for obj in review_data["unified_objects"]): metadata.potential_threat_level = 0 + metadata.title = metadata.title[0].upper() + metadata.title[1:] metadata.time = review_data["start"] return metadata except Exception as e: diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index a151e9ca9..a1e14452e 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1326,6 +1326,10 @@ "keyPlaceholder": "New key", "remove": "Remove" }, + "knownPlates": { + "namePlaceholder": "e.g., Wife's Car", + "platePlaceholder": "Plate number or regex" + }, "timezone": { "defaultOption": "Use browser timezone" }, diff --git a/web/src/components/config-form/section-configs/lpr.ts b/web/src/components/config-form/section-configs/lpr.ts index 4997d766f..0567c6cf4 100644 --- a/web/src/components/config-form/section-configs/lpr.ts +++ b/web/src/components/config-form/section-configs/lpr.ts @@ -67,6 +67,13 @@ const lpr: SectionConfigOverrides = { format: { "ui:options": { size: "md" }, }, + known_plates: { + "ui:field": "KnownPlatesField", + "ui:options": { + label: false, + suppressDescription: true, + }, + }, replace_rules: { "ui:field": "ReplaceRulesField", "ui:options": { diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts index 35f3b1ef7..89b1232bf 100644 --- a/web/src/components/config-form/section-configs/record.ts +++ b/web/src/components/config-form/section-configs/record.ts @@ -16,6 +16,16 @@ const record: SectionConfigOverrides = { }, }, ], + fieldDocs: { + "alerts.pre_capture": + "/configuration/record#pre-capture-and-post-capture", + "alerts.post_capture": + "/configuration/record#pre-capture-and-post-capture", + "detections.pre_capture": + "/configuration/record#pre-capture-and-post-capture", + "detections.post_capture": + "/configuration/record#pre-capture-and-post-capture", + }, restartRequired: [], fieldOrder: [ "enabled", diff --git a/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx b/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx index f09cc5406..74c237c3b 100644 --- a/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx +++ b/web/src/components/config-form/sectionExtras/CameraReviewStatusToggles.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import useSWR from "swr"; import { Trans } from "react-i18next"; import Heading from "@/components/ui/heading"; @@ -37,6 +37,21 @@ export default function CameraReviewStatusToggles({ const { payload: revDescState, send: sendRevDesc } = useReviewDescriptionState(cameraId); + // Sync WS runtime state when review genai transitions from disabled to enabled in config + const prevRevGenaiEnabled = useRef( + cameraConfig?.review?.genai?.enabled_in_config, + ); + + useEffect(() => { + const wasEnabled = prevRevGenaiEnabled.current; + const isEnabled = cameraConfig?.review?.genai?.enabled_in_config; + prevRevGenaiEnabled.current = isEnabled; + + if (!wasEnabled && isEnabled) { + sendRevDesc("ON"); + } + }, [cameraConfig?.review?.genai?.enabled_in_config, sendRevDesc]); + if (!selectedCamera || !cameraConfig) { return null; } diff --git a/web/src/components/config-form/theme/fields/KnownPlatesField.tsx b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx new file mode 100644 index 000000000..f710dcd11 --- /dev/null +++ b/web/src/components/config-form/theme/fields/KnownPlatesField.tsx @@ -0,0 +1,277 @@ +import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { + LuChevronDown, + LuChevronRight, + LuPlus, + LuTrash2, +} from "react-icons/lu"; +import type { ConfigFormContext } from "@/types/configForm"; +import get from "lodash/get"; +import { isSubtreeModified } from "../utils"; + +type KnownPlatesData = Record; + +export function KnownPlatesField(props: FieldProps) { + const { schema, formData, onChange, idSchema, disabled, readonly } = props; + const formContext = props.registry?.formContext as + | ConfigFormContext + | undefined; + + const { t } = useTranslation(["views/settings", "common"]); + + const data: KnownPlatesData = useMemo(() => { + if (!formData || typeof formData !== "object" || Array.isArray(formData)) { + return {}; + } + return formData as KnownPlatesData; + }, [formData]); + + const entries = useMemo(() => Object.entries(data), [data]); + + const title = (schema as RJSFSchema).title; + const description = (schema as RJSFSchema).description; + + const hasItems = entries.length > 0; + const emptyPath = useMemo(() => [] as FieldPathList, []); + const fieldPath = + (props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ?? + emptyPath; + + const isModified = useMemo(() => { + const baselineRoot = formContext?.baselineFormData; + const baselineValue = baselineRoot + ? get(baselineRoot, fieldPath) + : undefined; + return isSubtreeModified( + data, + baselineValue, + formContext?.overrides, + fieldPath, + formContext?.formData, + ); + }, [fieldPath, formContext, data]); + + const [open, setOpen] = useState(hasItems || isModified); + + useEffect(() => { + if (isModified) { + setOpen(true); + } + }, [isModified]); + + useEffect(() => { + if (hasItems) { + setOpen(true); + } + }, [hasItems]); + + const handleAddEntry = useCallback(() => { + const next = { ...data, "": [""] }; + onChange(next, fieldPath); + }, [data, fieldPath, onChange]); + + const handleRemoveEntry = useCallback( + (key: string) => { + const next = { ...data }; + delete next[key]; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleRenameKey = useCallback( + (oldKey: string, newKey: string) => { + if (oldKey === newKey) return; + // Preserve order by rebuilding the object + const next: KnownPlatesData = {}; + for (const [k, v] of Object.entries(data)) { + if (k === oldKey) { + next[newKey] = v; + } else { + next[k] = v; + } + } + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleAddPlate = useCallback( + (key: string) => { + const next = { ...data, [key]: [...(data[key] || []), ""] }; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleRemovePlate = useCallback( + (key: string, plateIndex: number) => { + const plates = [...(data[key] || [])]; + plates.splice(plateIndex, 1); + const next = { ...data, [key]: plates }; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleUpdatePlate = useCallback( + (key: string, plateIndex: number, value: string) => { + const plates = [...(data[key] || [])]; + plates[plateIndex] = value; + const next = { ...data, [key]: plates }; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const baseId = idSchema?.$id || "known_plates"; + const deleteLabel = t("button.delete", { + ns: "common", + defaultValue: "Delete", + }); + const namePlaceholder = t("configForm.knownPlates.namePlaceholder", { + ns: "views/settings", + }); + const platePlaceholder = t("configForm.knownPlates.platePlaceholder", { + ns: "views/settings", + }); + return ( + + + + +
+
+ + {title} + + {description && ( +

+ {description} +

+ )} +
+ {open ? ( + + ) : ( + + )} +
+
+
+ + + + {entries.map(([key, plates], entryIndex) => { + const entryId = `${baseId}-${entryIndex}`; + + return ( +
+
+ handleRenameKey(key, e.target.value)} + className="flex-1" + /> + +
+ +
+ {plates.map((plate, plateIndex) => ( +
+ + handleUpdatePlate(key, plateIndex, e.target.value) + } + className="flex-1" + /> + {plates.length > 1 && ( + + )} +
+ ))} + +
+
+ ); + })} + +
+ +
+
+
+
+
+ ); +} + +export default KnownPlatesField; diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index b7d619fe0..ae612d9ac 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -49,6 +49,7 @@ import { DetectorHardwareField } from "./fields/DetectorHardwareField"; import { ReplaceRulesField } from "./fields/ReplaceRulesField"; import { CameraInputsField } from "./fields/CameraInputsField"; import { DictAsYamlField } from "./fields/DictAsYamlField"; +import { KnownPlatesField } from "./fields/KnownPlatesField"; export interface FrigateTheme { widgets: RegistryWidgetsType; @@ -105,5 +106,6 @@ export const frigateTheme: FrigateTheme = { ReplaceRulesField: ReplaceRulesField, CameraInputsField: CameraInputsField, DictAsYamlField: DictAsYamlField, + KnownPlatesField: KnownPlatesField, }, }; diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 77cb8e3f4..4a54bb142 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -23,7 +23,7 @@ import { GeneralFilterContent } from "../filter/ReviewFilterGroup"; import { toast } from "sonner"; import axios, { AxiosError } from "axios"; import SaveExportOverlay from "./SaveExportOverlay"; -import { isIOS, isMobile } from "react-device-detect"; +import { isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -505,7 +505,6 @@ export default function MobileReviewSettingsDrawer({ setShowPreview={setShowExportPreview} /> { if (!open) { diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 5f119a86c..c74191957 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -818,6 +818,27 @@ export default function Settings() { [], ); + // Show save/undo all buttons only when changes span multiple sections + // or the single changed section is not the one currently being viewed + const showSaveAllButtons = useMemo(() => { + const pendingKeys = Object.keys(pendingDataBySection); + if (pendingKeys.length === 0) return false; + if (pendingKeys.length >= 2) return true; + + // Exactly one pending section — check if it matches the current view + const key = pendingKeys[0]; + const menuKey = pendingKeyToMenuKey(key); + if (menuKey !== pageToggle) return true; + + // For camera-scoped keys, also check if the camera matches + if (key.includes("::")) { + const cameraName = key.slice(0, key.indexOf("::")); + return cameraName !== selectedCamera; + } + + return false; + }, [pendingDataBySection, pendingKeyToMenuKey, pageToggle, selectedCamera]); + const handleSaveAll = useCallback(async () => { if ( !config || @@ -1491,7 +1512,7 @@ export default function Settings() { ); })} - {hasPendingChanges && ( + {showSaveAllButtons && (
@@ -1667,7 +1688,7 @@ export default function Settings() {
- {hasPendingChanges && ( + {showSaveAllButtons && (