diff --git a/frigate/data_processing/post/review_descriptions.py b/frigate/data_processing/post/review_descriptions.py index 7f1f65658..c5e51d612 100644 --- a/frigate/data_processing/post/review_descriptions.py +++ b/frigate/data_processing/post/review_descriptions.py @@ -40,7 +40,7 @@ logger = logging.getLogger(__name__) RECORDING_BUFFER_EXTENSION_PERCENT = 0.10 MIN_RECORDING_DURATION = 10 MAX_IMAGE_TOKENS = 24000 -MAX_FRAMES_PER_SECOND = 2 +MAX_FRAMES_PER_SECOND = 1 class ReviewDescriptionProcessor(PostProcessorApi): diff --git a/frigate/data_processing/post/types.py b/frigate/data_processing/post/types.py index b4deb1ddd..41887a20e 100644 --- a/frigate/data_processing/post/types.py +++ b/frigate/data_processing/post/types.py @@ -1,25 +1,48 @@ -from pydantic import BaseModel, ConfigDict, Field +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, StringConstraints + +ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=160)] class ReviewMetadata(BaseModel): model_config = ConfigDict(extra="ignore", protected_namespaces=()) - observations: list[str] = Field( - default_factory=list, - description="Chronological list of significant observations from the frames, written before the scene narrative is composed.", + observations: list[ObservationItem] = Field( + ..., + min_length=3, + max_length=15, + description=( + "Enumerate the significant observations across all frames, in " + "chronological order, BEFORE composing the scene narrative. " + "Include the very start of the activity — for example, a vehicle " + "entering the frame or pulling into the driveway — even if it " + "lasts only a few frames and the rest of the clip is dominated " + "by a longer activity. Include each arrival, departure, motion " + "event, object handled, and notable change in position or state. " + "Each item is a single concrete fact written as a complete " + "sentence. Do not summarize, interpret, or assign meaning here — " + "that belongs in the scene field." + ), ) title: str = Field( - description="A short title characterizing what took place and where, under 10 words." + max_length=80, + description="A short title characterizing what took place and where, under 10 words.", ) scene: str = Field( - description="A chronological narrative of what happens from start to finish.", + min_length=150, + max_length=600, + description="A chronological narrative of what happens from start to finish, drawing directly from the items in observations.", ) shortSummary: str = Field( - description="A brief 2-sentence summary of the scene, suitable for notifications." + min_length=70, + max_length=100, + description="A brief 2-sentence summary of the scene, suitable for notifications.", ) confidence: float = Field( ge=0.0, - description="Confidence in the analysis, from 0 to 1.", + le=1.0, + description="Confidence in the analysis as a decimal between 0.0 and 1.0, where 0.0 means no confidence and 1.0 means complete confidence. Express ONLY as a decimal.", ) potential_threat_level: int = Field( ge=0, diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index 203619dea..20bf1d6fb 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -2,6 +2,7 @@ import datetime import importlib +import json import logging import os import re @@ -9,6 +10,7 @@ from typing import Any, Callable, Optional import numpy as np from playhouse.shortcuts import model_to_dict +from pydantic import ValidationError from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum from frigate.const import CLIPS_DIR @@ -151,50 +153,6 @@ Each line represents a detection state, not necessarily unique individuals. The if "other_concerns" in schema.get("required", []): schema["required"].remove("other_concerns") - # Length hints injected into the schema as suggestions to the model - # (enforced by grammar-based providers like llama.cpp) but kept off the - # Pydantic model so a non-compliant response does not fail validation. - length_hints = { - "scene": {"minLength": 120, "maxLength": 600}, - "shortSummary": {"minLength": 70, "maxLength": 100}, - } - for field, hints in length_hints.items(): - prop = schema.get("properties", {}).get(field) - if prop is not None: - prop.update(hints) - - # observations is a chain-of-thought-by-schema field: forcing the model - # to enumerate concrete facts before writing scene/title surfaces details - # the narrative would otherwise gloss past (e.g. brief vehicle arrivals - # overshadowed by a longer activity). The minItems floor scales with - # event duration so longer clips get more observations. - observations_prop = schema.get("properties", {}).get("observations") - if observations_prop is not None: - duration_seconds = float(review_data.get("duration") or 0) - min_observations = max(3, round(duration_seconds / 5)) - max_observations = min_observations + 8 - observations_prop["description"] = ( - "Enumerate the significant observations across all frames, in " - "chronological order, BEFORE composing the scene narrative. " - "Include the very start of the activity — for example, a " - "vehicle entering the frame or pulling into the driveway — " - "even if it lasts only a few frames and the rest of the clip " - "is dominated by a longer activity. Include each arrival, " - "departure, motion event, object handled, and notable change " - "in position or state. Each item is a single concrete fact " - "written as a complete sentence (e.g., 'A blue sedan turns " - "from the street into the driveway', 'Nick exits the driver " - "side carrying a plant pot'). Do not summarize, interpret, or " - "assign meaning here — that belongs in the scene field." - ) - observations_prop["minItems"] = min_observations - observations_prop["maxItems"] = max_observations - observations_prop["items"] = {"type": "string", "minLength": 20} - - required = schema.setdefault("required", []) - if "observations" not in required: - required.append("observations") - # OpenAI strict mode requires additionalProperties: false on all objects schema["additionalProperties"] = False @@ -225,7 +183,35 @@ Each line represents a detection state, not necessarily unique individuals. The try: metadata = ReviewMetadata.model_validate_json(clean_json) + except ValidationError as ve: + # Constraint violations (length, item count, ranges) are logged + # at debug and the response is kept anyway — a slightly + # off-spec answer is still usable, and dropping the whole + # response loses the narrative content the model produced. + for err in ve.errors(): + loc = ".".join(str(p) for p in err["loc"]) or "" + logger.debug( + "Review metadata soft validation: %s — %s (input: %r)", + loc, + err["msg"], + err.get("input"), + ) + try: + raw = json.loads(clean_json) + except json.JSONDecodeError as je: + logger.error("Failed to parse review description JSON: %s", je) + return None + # observations is required on the model; fill an empty default + # if the response omitted it so attribute access stays safe. + raw.setdefault("observations", []) + metadata = ReviewMetadata.model_construct(**raw) + except Exception as e: + logger.error( + f"Failed to parse review description as the response did not match expected format. {e}" + ) + return None + try: # Normalize confidence if model returned a percentage (e.g. 85 instead of 0.85) if metadata.confidence > 1.0: metadata.confidence = min(metadata.confidence / 100.0, 1.0) @@ -238,10 +224,7 @@ Each line represents a detection state, not necessarily unique individuals. The metadata.time = review_data["start"] return metadata except Exception as e: - # rarely LLMs can fail to follow directions on output format - logger.warning( - f"Failed to parse review description as the response did not match expected format. {e}" - ) + logger.error(f"Failed to post-process review metadata: {e}") return None else: logger.debug( diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 5a50e4daf..7dfd0ea98 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -457,7 +457,13 @@ "enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams.", "disableLabel": "Disabled cameras", "disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.", - "enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes." + "enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.", + "friendlyName": { + "edit": "Edit camera display name", + "title": "Edit Display Name", + "description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.", + "rename": "Rename" + } }, "cameraConfig": { "add": "Add Camera", diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 897b5f262..bd16a98bd 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -65,10 +65,14 @@ import { globalCameraDefaultSections, buildOverrides, buildConfigDataForPath, + flattenOverrides, getBaseCameraSectionValue, sanitizeSectionData as sharedSanitizeSectionData, requiresRestartForOverrides as sharedRequiresRestartForOverrides, } from "@/utils/configUtil"; +import SaveAllPreviewPopover, { + type SaveAllPreviewItem, +} from "@/components/overlay/detail/SaveAllPreviewPopover"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import { useRestart } from "@/api/ws"; import type { @@ -913,6 +917,34 @@ export function ConfigSection({ ); }, [sectionConfig?.renderers, sectionPath, cameraName, setPendingData]); + // Build a flat list of pending field changes for this section only. + // Mirrors the global Save All preview but scoped to the current section so + // users can inspect what will be saved without leaving the section. + const sectionPreviewItems = useMemo(() => { + if (!hasChanges) return []; + if (!effectiveOverrides || typeof effectiveOverrides !== "object") { + return []; + } + const flattened = flattenOverrides(effectiveOverrides as JsonValue); + return flattened.map(({ path, value }) => ({ + scope: effectiveLevel, + cameraName, + profileName: profileName + ? (profileFriendlyName ?? profileName) + : undefined, + fieldPath: path ? `${sectionPath}.${path}` : sectionPath, + value, + })); + }, [ + hasChanges, + effectiveOverrides, + effectiveLevel, + cameraName, + profileName, + profileFriendlyName, + sectionPath, + ]); + if (!modifiedSchema) { return null; } @@ -1018,6 +1050,12 @@ export function ConfigSection({ defaultValue: "You have unsaved changes", })} + )}
diff --git a/web/src/components/overlay/dialog/TextEntryDialog.tsx b/web/src/components/overlay/dialog/TextEntryDialog.tsx index 4ee0876fc..38fcee657 100644 --- a/web/src/components/overlay/dialog/TextEntryDialog.tsx +++ b/web/src/components/overlay/dialog/TextEntryDialog.tsx @@ -1,3 +1,4 @@ +import ActivityIndicator from "@/components/indicators/activity-indicator"; import TextEntry from "@/components/input/TextEntry"; import { Button } from "@/components/ui/button"; import { @@ -19,7 +20,9 @@ type TextEntryDialogProps = { setOpen: (open: boolean) => void; onSave: (text: string) => void; defaultValue?: string; + placeholder?: string; allowEmpty?: boolean; + isSaving?: boolean; regexPattern?: RegExp; regexErrorMessage?: string; forbiddenPattern?: RegExp; @@ -33,7 +36,9 @@ export default function TextEntryDialog({ setOpen, onSave, defaultValue = "", + placeholder, allowEmpty = false, + isSaving = false, regexPattern, regexErrorMessage, forbiddenPattern, @@ -50,6 +55,7 @@ export default function TextEntryDialog({ - - diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index fd19da4f9..a4b7f2245 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -28,11 +28,7 @@ import useOptimisticState from "@/hooks/use-optimistic-state"; import { isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; -import type { - ConfigSectionData, - JsonObject, - JsonValue, -} from "@/types/configForm"; +import type { ConfigSectionData, JsonObject } from "@/types/configForm"; import useSWR from "swr"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; @@ -93,6 +89,7 @@ import { mutate } from "swr"; import { RJSFSchema } from "@rjsf/utils"; import { buildConfigDataForPath, + flattenOverrides, parseProfileFromSectionPath, prepareSectionSavePayload, PROFILE_ELIGIBLE_SECTIONS, @@ -190,25 +187,6 @@ const parsePendingDataKey = (pendingDataKey: string) => { }; }; -const flattenOverrides = ( - value: JsonValue | undefined, - path: string[] = [], -): Array<{ path: string; value: JsonValue }> => { - if (value === undefined) return []; - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return [{ path: path.join("."), value }]; - } - - const entries = Object.entries(value); - if (entries.length === 0) { - return [{ path: path.join("."), value: {} }]; - } - - return entries.flatMap(([key, entryValue]) => - flattenOverrides(entryValue, [...path, key]), - ); -}; - const createSectionPage = ( sectionKey: string, level: "global" | "camera", diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index fb233a457..82e54f784 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -219,6 +219,32 @@ export function buildOverrides( return current; } +// --------------------------------------------------------------------------- +// flattenOverrides — turn an overrides object into a list of leaf paths +// --------------------------------------------------------------------------- + +// Walks a nested overrides value and produces a flat list of `{ path, value }` +// entries, one per leaf. Used by save/preview UIs to enumerate the individual +// fields that will be changed. +export function flattenOverrides( + value: JsonValue | undefined, + path: string[] = [], +): Array<{ path: string; value: JsonValue }> { + if (value === undefined) return []; + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return [{ path: path.join("."), value }]; + } + + const entries = Object.entries(value); + if (entries.length === 0) { + return [{ path: path.join("."), value: {} }]; + } + + return entries.flatMap(([key, entryValue]) => + flattenOverrides(entryValue, [...path, key]), + ); +} + // --------------------------------------------------------------------------- // sanitizeSectionData — normalize config values and strip hidden fields // --------------------------------------------------------------------------- diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 86e9b7a31..472aee923 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -14,7 +14,7 @@ import { useTranslation } from "react-i18next"; import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog"; -import { LuPlus, LuTrash2 } from "react-icons/lu"; +import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; import { IoMdArrowRoundBack } from "react-icons/io"; import { isDesktop } from "react-device-detect"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; @@ -26,6 +26,12 @@ import axios from "axios"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; +import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import type { ProfileState } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; import { cn } from "@/lib/utils"; @@ -161,7 +167,13 @@ export default function CameraManagementView({ key={camera} className="flex flex-row items-center justify-between" > - +
+ + +
))} @@ -297,6 +309,103 @@ function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) { ); } +type CameraFriendlyNameEditorProps = { + cameraName: string; + onConfigChanged: () => Promise; +}; + +function CameraFriendlyNameEditor({ + cameraName, + onConfigChanged, +}: CameraFriendlyNameEditorProps) { + const { t } = useTranslation(["views/settings", "common"]); + const { data: config } = useSWR("config"); + const [open, setOpen] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name; + + const onSave = useCallback( + async (text: string) => { + if (isSaving) return; + setIsSaving(true); + + try { + await axios.put("config/set", { + requires_restart: 0, + config_data: { + cameras: { + [cameraName]: { + friendly_name: text.trim() || null, + }, + }, + }, + }); + + await onConfigChanged(); + setOpen(false); + + toast.success(t("toast.save.success", { ns: "common" }), { + position: "top-center", + }); + } catch (error) { + const errorMessage = + axios.isAxiosError(error) && + (error.response?.data?.message || error.response?.data?.detail) + ? error.response?.data?.message || error.response?.data?.detail + : t("toast.save.error.noMessage", { ns: "common" }); + + toast.error( + t("toast.save.error.title", { errorMessage, ns: "common" }), + { position: "top-center" }, + ); + } finally { + setIsSaving(false); + } + }, + [cameraName, isSaving, onConfigChanged, t], + ); + + const renameLabel = t("cameraManagement.streams.friendlyName.rename", { + ns: "views/settings", + }); + + return ( + <> + + + + + {renameLabel} + + + + ); +} + type CameraConfigEnableSwitchProps = { cameraName: string; setRestartDialogOpen: React.Dispatch>;