Compare commits

...

8 Commits

Author SHA1 Message Date
Josh Hawkins
88ab136f8e only show save all when multiple sections are changed
or if the section being changed is not currently being viewed
2026-04-06 22:06:54 -05:00
Josh Hawkins
8de6f2b4cf improve known_plates field in settings UI 2026-04-06 22:06:18 -05:00
Josh Hawkins
9962f8508f add ui docs links 2026-04-06 18:03:56 -05:00
Josh Hawkins
c7a10c93be update docs to clarify pre/post capture settings 2026-04-06 18:03:45 -05:00
Josh Hawkins
b83f56d4b0 remove unused object level 2026-04-06 17:36:22 -05:00
Josh Hawkins
23aa21ea82 auto-send ON genai review WS message when enabled_in_config transitions to true 2026-04-06 17:18:52 -05:00
Josh Hawkins
f33b07bc90 subscribe to review topic so ReviewDescriptionProcessor knows genai is enabled 2026-04-06 17:18:14 -05:00
Josh Hawkins
7c0e5a8f17 update dispatcher config reference on save 2026-04-06 17:17:36 -05:00
10 changed files with 413 additions and 3 deletions

View File

@ -123,6 +123,76 @@ record:
</TabItem> </TabItem>
</ConfigTabs> </ConfigTabs>
## 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.
<ConfigTabs>
<TabItem value="ui">
Navigate to <NavPath path="Settings > Global configuration > Recording" /> for global defaults, or <NavPath path="Settings > Camera configuration > (select camera) > Recording" /> 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 |
</TabItem>
<TabItem value="yaml">
```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
```
</TabItem>
</ConfigTabs>
- **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? ## 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. As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted.

View File

@ -694,6 +694,9 @@ def config_set(request: Request, body: AppConfigSetBody):
if request.app.stats_emitter is not None: if request.app.stats_emitter is not None:
request.app.stats_emitter.config = config 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:
if body.update_topic.startswith("config/cameras/"): if body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/") _, _, camera, field = body.update_topic.split("/")

View File

@ -92,6 +92,7 @@ class EmbeddingMaintainer(threading.Thread):
CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.add,
CameraConfigUpdateEnum.remove, CameraConfigUpdateEnum.remove,
CameraConfigUpdateEnum.object_genai, CameraConfigUpdateEnum.object_genai,
CameraConfigUpdateEnum.review,
CameraConfigUpdateEnum.review_genai, CameraConfigUpdateEnum.review_genai,
CameraConfigUpdateEnum.semantic_search, CameraConfigUpdateEnum.semantic_search,
], ],

View File

@ -1326,6 +1326,10 @@
"keyPlaceholder": "New key", "keyPlaceholder": "New key",
"remove": "Remove" "remove": "Remove"
}, },
"knownPlates": {
"namePlaceholder": "e.g., Wife's Car",
"platePlaceholder": "Plate number or regex"
},
"timezone": { "timezone": {
"defaultOption": "Use browser timezone" "defaultOption": "Use browser timezone"
}, },

View File

@ -67,6 +67,13 @@ const lpr: SectionConfigOverrides = {
format: { format: {
"ui:options": { size: "md" }, "ui:options": { size: "md" },
}, },
known_plates: {
"ui:field": "KnownPlatesField",
"ui:options": {
label: false,
suppressDescription: true,
},
},
replace_rules: { replace_rules: {
"ui:field": "ReplaceRulesField", "ui:field": "ReplaceRulesField",
"ui:options": { "ui:options": {

View File

@ -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: [], restartRequired: [],
fieldOrder: [ fieldOrder: [
"enabled", "enabled",

View File

@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useEffect, useMemo, useRef } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
@ -37,6 +37,21 @@ export default function CameraReviewStatusToggles({
const { payload: revDescState, send: sendRevDesc } = const { payload: revDescState, send: sendRevDesc } =
useReviewDescriptionState(cameraId); 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) { if (!selectedCamera || !cameraConfig) {
return null; return null;
} }

View File

@ -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<string, string[]>;
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 (
<Card className="w-full">
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer p-4 transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between">
<div>
<CardTitle
className={cn("text-sm", isModified && "text-danger")}
>
{title}
</CardTitle>
{description && (
<p className="mt-1 text-xs text-muted-foreground">
{description}
</p>
)}
</div>
{open ? (
<LuChevronDown className="h-4 w-4" />
) : (
<LuChevronRight className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-3 p-4 pt-0">
{entries.map(([key, plates], entryIndex) => {
const entryId = `${baseId}-${entryIndex}`;
return (
<div
key={entryIndex}
className="space-y-2 rounded-md border p-3"
>
<div className="flex items-center gap-2">
<Input
id={`${entryId}-key`}
defaultValue={key}
placeholder={namePlaceholder}
disabled={disabled || readonly}
onBlur={(e) => handleRenameKey(key, e.target.value)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveEntry(key)}
disabled={disabled || readonly}
aria-label={deleteLabel}
title={deleteLabel}
className="shrink-0"
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
<div className="ml-1 space-y-2 border-l-2 border-muted-foreground/20 pl-3">
{plates.map((plate, plateIndex) => (
<div key={plateIndex} className="flex items-center gap-2">
<Input
id={`${entryId}-plate-${plateIndex}`}
value={plate}
placeholder={platePlaceholder}
disabled={disabled || readonly}
onChange={(e) =>
handleUpdatePlate(key, plateIndex, e.target.value)
}
className="flex-1"
/>
{plates.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemovePlate(key, plateIndex)}
disabled={disabled || readonly}
aria-label={deleteLabel}
title={deleteLabel}
className="shrink-0"
>
<LuTrash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddPlate(key)}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", {
ns: "common",
defaultValue: "Add",
})}
</Button>
</div>
</div>
);
})}
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddEntry}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("button.add", { ns: "common", defaultValue: "Add" })}
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
export default KnownPlatesField;

View File

@ -49,6 +49,7 @@ import { DetectorHardwareField } from "./fields/DetectorHardwareField";
import { ReplaceRulesField } from "./fields/ReplaceRulesField"; import { ReplaceRulesField } from "./fields/ReplaceRulesField";
import { CameraInputsField } from "./fields/CameraInputsField"; import { CameraInputsField } from "./fields/CameraInputsField";
import { DictAsYamlField } from "./fields/DictAsYamlField"; import { DictAsYamlField } from "./fields/DictAsYamlField";
import { KnownPlatesField } from "./fields/KnownPlatesField";
export interface FrigateTheme { export interface FrigateTheme {
widgets: RegistryWidgetsType; widgets: RegistryWidgetsType;
@ -105,5 +106,6 @@ export const frigateTheme: FrigateTheme = {
ReplaceRulesField: ReplaceRulesField, ReplaceRulesField: ReplaceRulesField,
CameraInputsField: CameraInputsField, CameraInputsField: CameraInputsField,
DictAsYamlField: DictAsYamlField, DictAsYamlField: DictAsYamlField,
KnownPlatesField: KnownPlatesField,
}, },
}; };

View File

@ -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 () => { const handleSaveAll = useCallback(async () => {
if ( if (
!config || !config ||
@ -1491,7 +1512,7 @@ export default function Settings() {
); );
})} })}
</div> </div>
{hasPendingChanges && ( {showSaveAllButtons && (
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4"> <div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1667,7 +1688,7 @@ export default function Settings() {
</Heading> </Heading>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasPendingChanges && ( {showSaveAllButtons && (
<div <div
className={cn( className={cn(
"flex flex-row items-center gap-2", "flex flex-row items-center gap-2",