diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index a9e237e70..e35b64762 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -429,7 +429,10 @@ class WebPushClient(Communicator): else: title = base_title - message = payload["after"]["data"]["metadata"]["shortSummary"] + if payload["after"]["data"]["metadata"].get("shortSummary"): + message = payload["after"]["data"]["metadata"]["shortSummary"] + else: + message = f"Detected on {camera_name}" else: zone_names = payload["after"]["data"]["zones"] formatted_zone_names = [] diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index f767a5c2f..7ebb46424 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -1073,10 +1073,6 @@ class LicensePlateProcessingMixin: top_score = score top_box = bbox - if score > top_score: - top_score = score - top_box = bbox - # Return the top scoring bounding box if found if top_box is not None: # expand box by 5% to help with OCR @@ -1092,9 +1088,6 @@ class LicensePlateProcessingMixin: ] ).clip(0, [input.shape[1], input.shape[0]] * 2) - logger.debug( - f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}" - ) return tuple(int(x) for x in expanded_box) # type: ignore[return-value] else: return None # No detection above the threshold @@ -1360,8 +1353,8 @@ class LicensePlateProcessingMixin: ) # check that license plate is valid - # double the value because we've doubled the size of the car - if license_plate_area < self.config.cameras[camera].lpr.min_area * 2: + # quadruple the value because we've doubled both dimensions of the car + if license_plate_area < self.config.cameras[camera].lpr.min_area * 4: logger.debug(f"{camera}: License plate is less than min_area") return @@ -1465,6 +1458,7 @@ class LicensePlateProcessingMixin: license_plate_frame, ) + logger.debug(f"{camera}: Found license plate. Bounding box: {list(plate_box)}") logger.debug(f"{camera}: Running plate recognition for id: {id}.") # run detection, returns results sorted by confidence, best first diff --git a/frigate/record/export.py b/frigate/record/export.py index e06b8f68d..32df8be75 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -13,6 +13,7 @@ from enum import Enum from pathlib import Path from typing import Callable, Optional +import pytz # type: ignore[import-untyped] from peewee import DoesNotExist from frigate.config import FfmpegConfig, FrigateConfig @@ -344,7 +345,19 @@ class RecordingExporter(threading.Thread): return proc.returncode, "".join(captured) def get_datetime_from_timestamp(self, timestamp: int) -> str: - # return in iso format + # return in iso format using the configured ui.timezone when set, + # so the auto-generated export name reflects local time rather + # than the container's UTC clock + tz_name = self.config.ui.timezone + if tz_name: + try: + tz = pytz.timezone(tz_name) + except pytz.UnknownTimeZoneError: + tz = None + if tz is not None: + return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime( + "%Y-%m-%d %H:%M:%S" + ) return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") def _chapter_metadata_path(self) -> str: @@ -538,12 +551,18 @@ class RecordingExporter(threading.Thread): start_file = f"{file_start}{self.start_time}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}" selected_preview = None + # Preview frames are written at most 1-2 fps during activity + # and as little as one every 30s during quiet periods, so a + # short export window can contain zero frames. Track the most + # recent frame before the window as a fallback. + fallback_preview = None for file in sorted(os.listdir(preview_dir)): if not file.startswith(file_start): continue if file < start_file: + fallback_preview = os.path.join(preview_dir, file) continue if file > end_file: @@ -552,6 +571,9 @@ class RecordingExporter(threading.Thread): selected_preview = os.path.join(preview_dir, file) break + if not selected_preview: + selected_preview = fallback_preview + if not selected_preview: return "" diff --git a/frigate/test/test_export_progress.py b/frigate/test/test_export_progress.py index 616a63503..903914815 100644 --- a/frigate/test/test_export_progress.py +++ b/frigate/test/test_export_progress.py @@ -1,6 +1,9 @@ """Tests for export progress tracking, broadcast, and FFmpeg parsing.""" import io +import os +import shutil +import tempfile import unittest from unittest.mock import MagicMock, patch @@ -363,6 +366,121 @@ class TestBroadcastAggregation(unittest.TestCase): assert job.progress_percent == 33.0 +class TestGetDatetimeFromTimestamp(unittest.TestCase): + """Auto-generated export name should honor config.ui.timezone, not + fall back to the container's UTC clock when a timezone is configured. + """ + + def test_uses_configured_ui_timezone(self) -> None: + exporter = _make_exporter() + exporter.config.ui.timezone = "America/New_York" + # 2025-01-15 12:00:00 UTC is 07:00:00 EST + assert exporter.get_datetime_from_timestamp(1736942400) == "2025-01-15 07:00:00" + + def test_falls_back_to_local_when_timezone_unset(self) -> None: + exporter = _make_exporter() + exporter.config.ui.timezone = None + # No assertion on the exact wall-clock value — just confirm no + # exception and that pytz isn't required when the field is unset. + assert isinstance(exporter.get_datetime_from_timestamp(1736942400), str) + + def test_invalid_timezone_falls_back_to_local(self) -> None: + exporter = _make_exporter() + exporter.config.ui.timezone = "Not/A_Real_Zone" + assert isinstance(exporter.get_datetime_from_timestamp(1736942400), str) + + +class TestSaveThumbnailFromPreviewFrames(unittest.TestCase): + """Short exports in the current hour can fall between preview frame + writes (1-2 fps during activity, every 30s otherwise). When no frame + falls inside the export window, save_thumbnail should fall back to + the most recent prior frame instead of returning no thumbnail.""" + + def setUp(self) -> None: + self.tmp_root = tempfile.mkdtemp(prefix="frigate_thumb_test_") + self.preview_dir = os.path.join(self.tmp_root, "cache", "preview_frames") + self.export_clips = os.path.join(self.tmp_root, "clips", "export") + os.makedirs(self.preview_dir, exist_ok=True) + os.makedirs(self.export_clips, exist_ok=True) + + def tearDown(self) -> None: + shutil.rmtree(self.tmp_root, ignore_errors=True) + + def _write_frame(self, camera: str, frame_time: float) -> str: + path = os.path.join(self.preview_dir, f"preview_{camera}-{frame_time}.webp") + with open(path, "wb") as f: + f.write(b"fake-webp-bytes") + return path + + def _make_short_current_hour_exporter(self) -> RecordingExporter: + # Use a "now-ish" timestamp so save_thumbnail's start-of-hour + # comparison takes the current-hour branch (preview frames). + import datetime + + now = datetime.datetime.now(datetime.timezone.utc).timestamp() + exporter = _make_exporter() + exporter.export_id = "thumb_short" + exporter.start_time = now + exporter.end_time = now + 3 + return exporter + + def test_short_export_falls_back_to_prior_preview_frame(self) -> None: + exporter = self._make_short_current_hour_exporter() + # Most recent preview frame is 10s before the export window + prior = self._write_frame(exporter.camera, exporter.start_time - 10.0) + thumb_target = os.path.join(self.export_clips, f"{exporter.export_id}.webp") + + with ( + patch( + "frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache") + ), + patch( + "frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips") + ), + ): + result = exporter.save_thumbnail(exporter.export_id) + + assert result == thumb_target + assert os.path.isfile(thumb_target) + with open(thumb_target, "rb") as f, open(prior, "rb") as src: + assert f.read() == src.read() + + def test_returns_empty_when_no_preview_frames_exist(self) -> None: + exporter = self._make_short_current_hour_exporter() + + with ( + patch( + "frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache") + ), + patch( + "frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips") + ), + ): + result = exporter.save_thumbnail(exporter.export_id) + + assert result == "" + + def test_prefers_in_window_frame_over_prior_frame(self) -> None: + exporter = self._make_short_current_hour_exporter() + self._write_frame(exporter.camera, exporter.start_time - 10.0) + in_window = self._write_frame(exporter.camera, exporter.start_time + 1.0) + thumb_target = os.path.join(self.export_clips, f"{exporter.export_id}.webp") + + with ( + patch( + "frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache") + ), + patch( + "frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips") + ), + ): + result = exporter.save_thumbnail(exporter.export_id) + + assert result == thumb_target + with open(thumb_target, "rb") as f, open(in_window, "rb") as src: + assert f.read() == src.read() + + class TestSchedulesCleanup(unittest.TestCase): def test_schedule_job_cleanup_removes_after_delay(self) -> None: config = MagicMock() diff --git a/web/e2e/specs/navigation.spec.ts b/web/e2e/specs/navigation.spec.ts index 592247186..e14887c15 100644 --- a/web/e2e/specs/navigation.spec.ts +++ b/web/e2e/specs/navigation.spec.ts @@ -69,17 +69,18 @@ test.describe("Navigation — conditional items @critical", () => { ).toBeVisible(); }); - test("/chat is hidden when genai.model is none (desktop)", async ({ + test("/chat is hidden when no agent has the chat role (desktop)", async ({ frigateApp, }) => { test.skip(frigateApp.isMobile, "Desktop sidebar"); await frigateApp.installDefaults({ config: { genai: { - enabled: false, - provider: "ollama", - model: "none", - base_url: "", + descriptions_only: { + provider: "ollama", + model: "llava", + roles: ["descriptions"], + }, }, }, }); @@ -89,12 +90,20 @@ test.describe("Navigation — conditional items @critical", () => { ).toHaveCount(0); }); - test("/chat is visible when genai.model is set (desktop)", async ({ + test("/chat is visible when an agent has the chat role (desktop)", async ({ frigateApp, }) => { test.skip(frigateApp.isMobile, "Desktop sidebar"); await frigateApp.installDefaults({ - config: { genai: { enabled: true, model: "llava" } }, + config: { + genai: { + chat_agent: { + provider: "ollama", + model: "llava", + roles: ["chat"], + }, + }, + }, }); await frigateApp.goto("/"); await expect( diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index d1a1322ac..b3ab45f98 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -93,6 +93,14 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { useSWR("profiles"); const logoutUrl = config?.proxy?.logout_url || "/api/logout"; + const hasChatAgent = useMemo( + () => + Object.values(config?.genai ?? {}).some((agent) => + agent?.roles?.includes("chat"), + ), + [config?.genai], + ); + // languages const languages = useMemo(() => { @@ -511,7 +519,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { {t("menu.classification")} - {config?.genai?.model !== "none" && ( + {hasChatAgent && ( { setIsStarting(true); + const toastId = toast.loading( + t("dialog.starting", { ns: "views/replay" }), + { position: "top-center" }, + ); axios .post("debug_replay/start", { @@ -100,6 +104,7 @@ export default function SearchResultActions({ .then((response) => { if (response.status === 200) { toast.success(t("dialog.toast.success", { ns: "views/replay" }), { + id: toastId, position: "top-center", }); navigate("/replay"); @@ -115,6 +120,7 @@ export default function SearchResultActions({ toast.error( t("dialog.toast.alreadyActive", { ns: "views/replay" }), { + id: toastId, position: "top-center", closeButton: true, dismissible: false, @@ -129,6 +135,7 @@ export default function SearchResultActions({ ); } else { toast.error(t("dialog.toast.error", { error: errorMessage }), { + id: toastId, position: "top-center", }); } diff --git a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx index 4fbf34ca7..2faa36cd9 100644 --- a/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx +++ b/web/src/components/overlay/detail/AnnotationOffsetSlider.tsx @@ -1,4 +1,6 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { flushSync } from "react-dom"; +import { throttle } from "lodash"; import { Slider } from "@/components/ui/slider"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; @@ -19,11 +21,21 @@ import { useIsAdmin } from "@/hooks/use-is-admin"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { Link } from "react-router-dom"; +const SLIDER_DRAG_THROTTLE_MS = 80; + type Props = { className?: string; + // Optional side-effect invoked atomically with setAnnotationOffset (inside + // flushSync) so callers like the timeline panel can re-seek the video in the + // same React commit as the offset state update — preventing a one-frame + // overlay mismatch where annotationOffset has changed but currentTime has not. + onApplyOffset?: (newOffset: number) => void; }; -export default function AnnotationOffsetSlider({ className }: Props) { +export default function AnnotationOffsetSlider({ + className, + onApplyOffset, +}: Props) { const { annotationOffset, setAnnotationOffset, camera } = useDetailStream(); const isAdmin = useIsAdmin(); const { getLocaleDocUrl } = useDocDomain(); @@ -31,31 +43,62 @@ export default function AnnotationOffsetSlider({ className }: Props) { const { t } = useTranslation(["views/explore"]); const [isSaving, setIsSaving] = useState(false); + const applyOffset = useCallback( + (newOffset: number) => { + flushSync(() => { + setAnnotationOffset(newOffset); + onApplyOffset?.(newOffset); + }); + }, + [setAnnotationOffset, onApplyOffset], + ); + + const throttledApplyOffset = useMemo( + () => + throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, { + leading: true, + trailing: true, + }), + [applyOffset], + ); + + useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]); + const handleChange = useCallback( (values: number[]) => { if (!values || values.length === 0) return; - const valueMs = values[0]; - setAnnotationOffset(valueMs); + throttledApplyOffset(values[0]); }, - [setAnnotationOffset], + [throttledApplyOffset], + ); + + const handleCommit = useCallback( + (values: number[]) => { + if (!values || values.length === 0) return; + // Ensure the final value lands even if it would otherwise be discarded + // by the trailing edge of the throttle window. + throttledApplyOffset.cancel(); + applyOffset(values[0]); + }, + [throttledApplyOffset, applyOffset], ); const stepOffset = useCallback( (delta: number) => { - setAnnotationOffset((prev) => { - const next = prev + delta; - return Math.max( - ANNOTATION_OFFSET_MIN, - Math.min(ANNOTATION_OFFSET_MAX, next), - ); - }); + const next = Math.max( + ANNOTATION_OFFSET_MIN, + Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta), + ); + throttledApplyOffset.cancel(); + applyOffset(next); }, - [setAnnotationOffset], + [annotationOffset, applyOffset, throttledApplyOffset], ); const reset = useCallback(() => { - setAnnotationOffset(0); - }, [setAnnotationOffset]); + throttledApplyOffset.cancel(); + applyOffset(0); + }, [applyOffset, throttledApplyOffset]); const save = useCallback(async () => { setIsSaving(true); @@ -130,6 +173,7 @@ export default function AnnotationOffsetSlider({ className }: Props) { max={ANNOTATION_OFFSET_MAX} step={ANNOTATION_OFFSET_STEP} onValueChange={handleChange} + onValueCommit={handleCommit} /> {controlsExpanded && (
- +
diff --git a/web/src/components/timeline/EventMenu.tsx b/web/src/components/timeline/EventMenu.tsx index ee0de8f15..71aa12bd6 100644 --- a/web/src/components/timeline/EventMenu.tsx +++ b/web/src/components/timeline/EventMenu.tsx @@ -53,6 +53,10 @@ export default function EventMenu({ const handleDebugReplay = useCallback( (event: Event) => { setIsStarting(true); + const toastId = toast.loading( + t("dialog.starting", { ns: "views/replay" }), + { position: "top-center" }, + ); axios .post("debug_replay/start", { @@ -63,6 +67,7 @@ export default function EventMenu({ .then((response) => { if (response.status === 200) { toast.success(t("dialog.toast.success", { ns: "views/replay" }), { + id: toastId, position: "top-center", }); navigate("/replay"); @@ -78,6 +83,7 @@ export default function EventMenu({ toast.error( t("dialog.toast.alreadyActive", { ns: "views/replay" }), { + id: toastId, position: "top-center", closeButton: true, dismissible: false, @@ -92,6 +98,7 @@ export default function EventMenu({ ); } else { toast.error(t("dialog.toast.error", { error: errorMessage }), { + id: toastId, position: "top-center", }); } @@ -106,7 +113,7 @@ export default function EventMenu({ return ( <> - +
diff --git a/web/src/hooks/use-navigation.ts b/web/src/hooks/use-navigation.ts index 031411c1a..dcf5da0ca 100644 --- a/web/src/hooks/use-navigation.ts +++ b/web/src/hooks/use-navigation.ts @@ -28,6 +28,14 @@ export default function useNavigation( }); const isAdmin = useIsAdmin(); + const hasChatAgent = useMemo( + () => + Object.values(config?.genai ?? {}).some((agent) => + agent?.roles?.includes("chat"), + ), + [config?.genai], + ); + return useMemo( () => [ @@ -89,9 +97,9 @@ export default function useNavigation( icon: MdChat, title: "menu.chat", url: "/chat", - enabled: isDesktop && isAdmin && config?.genai?.model !== "none", + enabled: isDesktop && isAdmin && hasChatAgent, }, ] as NavData[], - [config?.face_recognition?.enabled, config?.genai?.model, variant, isAdmin], + [config?.face_recognition?.enabled, hasChatAgent, variant, isAdmin], ); } diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 9a745b76c..2b9a05a1a 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -382,6 +382,18 @@ export type AllGroupsStreamingSettings = { [groupName: string]: GroupStreamingSettings; }; +export type GenAIRole = "chat" | "descriptions" | "embeddings"; + +export type GenAIAgentConfig = { + api_key?: string; + base_url?: string; + model: string; + provider?: string; + roles: GenAIRole[]; + provider_options?: Record; + runtime_options?: Record; +}; + export interface FrigateConfig { version: string; safe_mode: boolean; @@ -478,12 +490,7 @@ export interface FrigateConfig { retry_interval: number; }; - genai: { - provider: string; - base_url?: string; - api_key?: string; - model: string; - }; + genai: Record; go2rtc: { streams: Record;