Miscellaneous fixes (#23092)

* lpr fixes

- remove duplicate code
- fix min_area check for non frigate+ code path
- move log outside of non frigate+ code path

* only show chat link when a genai provider is configured with the chat role

* respect ui.timezone when generating fallback export names

* reapply radix pointer events fix to call sites that use navigate()

* formatting

* fall back to prior preview frame for short export thumbnails

* fix typing

* fix e2e test for chat navigation

* batch annotation offset to seek atomically and throttle slider drag

* add debug replay loading toast for explore actions

* Improve handling of webpush missing shortSummary

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Josh Hawkins 2026-05-02 17:35:42 -05:00 committed by GitHub
parent 6a2b914b10
commit 147cd5cc2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 373 additions and 82 deletions

View File

@ -429,7 +429,10 @@ class WebPushClient(Communicator):
else: else:
title = base_title 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: else:
zone_names = payload["after"]["data"]["zones"] zone_names = payload["after"]["data"]["zones"]
formatted_zone_names = [] formatted_zone_names = []

View File

@ -1073,10 +1073,6 @@ class LicensePlateProcessingMixin:
top_score = score top_score = score
top_box = bbox top_box = bbox
if score > top_score:
top_score = score
top_box = bbox
# Return the top scoring bounding box if found # Return the top scoring bounding box if found
if top_box is not None: if top_box is not None:
# expand box by 5% to help with OCR # expand box by 5% to help with OCR
@ -1092,9 +1088,6 @@ class LicensePlateProcessingMixin:
] ]
).clip(0, [input.shape[1], input.shape[0]] * 2) ).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] return tuple(int(x) for x in expanded_box) # type: ignore[return-value]
else: else:
return None # No detection above the threshold return None # No detection above the threshold
@ -1360,8 +1353,8 @@ class LicensePlateProcessingMixin:
) )
# check that license plate is valid # check that license plate is valid
# double the value because we've doubled the size of the car # quadruple the value because we've doubled both dimensions of the car
if license_plate_area < self.config.cameras[camera].lpr.min_area * 2: if license_plate_area < self.config.cameras[camera].lpr.min_area * 4:
logger.debug(f"{camera}: License plate is less than min_area") logger.debug(f"{camera}: License plate is less than min_area")
return return
@ -1465,6 +1458,7 @@ class LicensePlateProcessingMixin:
license_plate_frame, 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}.") logger.debug(f"{camera}: Running plate recognition for id: {id}.")
# run detection, returns results sorted by confidence, best first # run detection, returns results sorted by confidence, best first

View File

@ -13,6 +13,7 @@ from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
import pytz # type: ignore[import-untyped]
from peewee import DoesNotExist from peewee import DoesNotExist
from frigate.config import FfmpegConfig, FrigateConfig from frigate.config import FfmpegConfig, FrigateConfig
@ -344,7 +345,19 @@ class RecordingExporter(threading.Thread):
return proc.returncode, "".join(captured) return proc.returncode, "".join(captured)
def get_datetime_from_timestamp(self, timestamp: int) -> str: 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") return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def _chapter_metadata_path(self) -> str: 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}" start_file = f"{file_start}{self.start_time}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}" end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}"
selected_preview = None 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)): for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start): if not file.startswith(file_start):
continue continue
if file < start_file: if file < start_file:
fallback_preview = os.path.join(preview_dir, file)
continue continue
if file > end_file: if file > end_file:
@ -552,6 +571,9 @@ class RecordingExporter(threading.Thread):
selected_preview = os.path.join(preview_dir, file) selected_preview = os.path.join(preview_dir, file)
break break
if not selected_preview:
selected_preview = fallback_preview
if not selected_preview: if not selected_preview:
return "" return ""

View File

@ -1,6 +1,9 @@
"""Tests for export progress tracking, broadcast, and FFmpeg parsing.""" """Tests for export progress tracking, broadcast, and FFmpeg parsing."""
import io import io
import os
import shutil
import tempfile
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -363,6 +366,121 @@ class TestBroadcastAggregation(unittest.TestCase):
assert job.progress_percent == 33.0 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): class TestSchedulesCleanup(unittest.TestCase):
def test_schedule_job_cleanup_removes_after_delay(self) -> None: def test_schedule_job_cleanup_removes_after_delay(self) -> None:
config = MagicMock() config = MagicMock()

View File

@ -69,17 +69,18 @@ test.describe("Navigation — conditional items @critical", () => {
).toBeVisible(); ).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, frigateApp,
}) => { }) => {
test.skip(frigateApp.isMobile, "Desktop sidebar"); test.skip(frigateApp.isMobile, "Desktop sidebar");
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: { config: {
genai: { genai: {
enabled: false, descriptions_only: {
provider: "ollama", provider: "ollama",
model: "none", model: "llava",
base_url: "", roles: ["descriptions"],
},
}, },
}, },
}); });
@ -89,12 +90,20 @@ test.describe("Navigation — conditional items @critical", () => {
).toHaveCount(0); ).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, frigateApp,
}) => { }) => {
test.skip(frigateApp.isMobile, "Desktop sidebar"); test.skip(frigateApp.isMobile, "Desktop sidebar");
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: { genai: { enabled: true, model: "llava" } }, config: {
genai: {
chat_agent: {
provider: "ollama",
model: "llava",
roles: ["chat"],
},
},
},
}); });
await frigateApp.goto("/"); await frigateApp.goto("/");
await expect( await expect(

View File

@ -93,6 +93,14 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
useSWR<ProfilesApiResponse>("profiles"); useSWR<ProfilesApiResponse>("profiles");
const logoutUrl = config?.proxy?.logout_url || "/api/logout"; const logoutUrl = config?.proxy?.logout_url || "/api/logout";
const hasChatAgent = useMemo(
() =>
Object.values(config?.genai ?? {}).some((agent) =>
agent?.roles?.includes("chat"),
),
[config?.genai],
);
// languages // languages
const languages = useMemo(() => { const languages = useMemo(() => {
@ -511,7 +519,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>{t("menu.classification")}</span> <span>{t("menu.classification")}</span>
</MenuItem> </MenuItem>
</Link> </Link>
{config?.genai?.model !== "none" && ( {hasChatAgent && (
<Link to="/chat"> <Link to="/chat">
<MenuItem <MenuItem
className="flex w-full items-center p-2 text-sm" className="flex w-full items-center p-2 text-sm"

View File

@ -90,6 +90,10 @@ export default function SearchResultActions({
const handleDebugReplay = useCallback( const handleDebugReplay = useCallback(
(event: SearchResult) => { (event: SearchResult) => {
setIsStarting(true); setIsStarting(true);
const toastId = toast.loading(
t("dialog.starting", { ns: "views/replay" }),
{ position: "top-center" },
);
axios axios
.post("debug_replay/start", { .post("debug_replay/start", {
@ -100,6 +104,7 @@ export default function SearchResultActions({
.then((response) => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
toast.success(t("dialog.toast.success", { ns: "views/replay" }), { toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
id: toastId,
position: "top-center", position: "top-center",
}); });
navigate("/replay"); navigate("/replay");
@ -115,6 +120,7 @@ export default function SearchResultActions({
toast.error( toast.error(
t("dialog.toast.alreadyActive", { ns: "views/replay" }), t("dialog.toast.alreadyActive", { ns: "views/replay" }),
{ {
id: toastId,
position: "top-center", position: "top-center",
closeButton: true, closeButton: true,
dismissible: false, dismissible: false,
@ -129,6 +135,7 @@ export default function SearchResultActions({
); );
} else { } else {
toast.error(t("dialog.toast.error", { error: errorMessage }), { toast.error(t("dialog.toast.error", { error: errorMessage }), {
id: toastId,
position: "top-center", position: "top-center",
}); });
} }

View File

@ -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 { Slider } from "@/components/ui/slider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; 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 { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
const SLIDER_DRAG_THROTTLE_MS = 80;
type Props = { type Props = {
className?: string; 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 { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
@ -31,31 +43,62 @@ export default function AnnotationOffsetSlider({ className }: Props) {
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
const [isSaving, setIsSaving] = useState(false); 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( const handleChange = useCallback(
(values: number[]) => { (values: number[]) => {
if (!values || values.length === 0) return; if (!values || values.length === 0) return;
const valueMs = values[0]; throttledApplyOffset(values[0]);
setAnnotationOffset(valueMs);
}, },
[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( const stepOffset = useCallback(
(delta: number) => { (delta: number) => {
setAnnotationOffset((prev) => { const next = Math.max(
const next = prev + delta; ANNOTATION_OFFSET_MIN,
return Math.max( Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
ANNOTATION_OFFSET_MIN, );
Math.min(ANNOTATION_OFFSET_MAX, next), throttledApplyOffset.cancel();
); applyOffset(next);
});
}, },
[setAnnotationOffset], [annotationOffset, applyOffset, throttledApplyOffset],
); );
const reset = useCallback(() => { const reset = useCallback(() => {
setAnnotationOffset(0); throttledApplyOffset.cancel();
}, [setAnnotationOffset]); applyOffset(0);
}, [applyOffset, throttledApplyOffset]);
const save = useCallback(async () => { const save = useCallback(async () => {
setIsSaving(true); setIsSaving(true);
@ -130,6 +173,7 @@ export default function AnnotationOffsetSlider({ className }: Props) {
max={ANNOTATION_OFFSET_MAX} max={ANNOTATION_OFFSET_MAX}
step={ANNOTATION_OFFSET_STEP} step={ANNOTATION_OFFSET_STEP}
onValueChange={handleChange} onValueChange={handleChange}
onValueCommit={handleCommit}
/> />
</div> </div>
<Button <Button

View File

@ -1,7 +1,9 @@
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios"; import axios from "axios";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { flushSync } from "react-dom";
import { throttle } from "lodash";
import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu"; import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@ -19,6 +21,8 @@ import {
ANNOTATION_OFFSET_STEP, ANNOTATION_OFFSET_STEP,
} from "@/lib/const"; } from "@/lib/const";
const SLIDER_DRAG_THROTTLE_MS = 80;
type AnnotationSettingsPaneProps = { type AnnotationSettingsPaneProps = {
event: Event; event: Event;
annotationOffset: number; annotationOffset: number;
@ -38,30 +42,64 @@ export function AnnotationSettingsPane({
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleSliderChange = useCallback( // flushSync ensures setAnnotationOffset commits synchronously so the
(values: number[]) => { // useLayoutEffect in TrackingDetails (which seeks the video and sets
if (!values || values.length === 0) return; // currentTime in response) runs before the browser paints — preventing a
setAnnotationOffset(values[0]); // one-frame overlay mismatch where annotationOffset has changed but
}, // currentTime has not.
[setAnnotationOffset], const applyOffset = useCallback(
); (newOffset: number) => {
flushSync(() => {
const stepOffset = useCallback( setAnnotationOffset(newOffset);
(delta: number) => {
setAnnotationOffset((prev) => {
const next = prev + delta;
return Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, next),
);
}); });
}, },
[setAnnotationOffset], [setAnnotationOffset],
); );
const throttledApplyOffset = useMemo(
() =>
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
leading: true,
trailing: true,
}),
[applyOffset],
);
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
const handleSliderChange = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
throttledApplyOffset(values[0]);
},
[throttledApplyOffset],
);
const handleSliderCommit = useCallback(
(values: number[]) => {
if (!values || values.length === 0) return;
throttledApplyOffset.cancel();
applyOffset(values[0]);
},
[throttledApplyOffset, applyOffset],
);
const stepOffset = useCallback(
(delta: number) => {
const next = Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
);
throttledApplyOffset.cancel();
applyOffset(next);
},
[annotationOffset, applyOffset, throttledApplyOffset],
);
const reset = useCallback(() => { const reset = useCallback(() => {
setAnnotationOffset(0); throttledApplyOffset.cancel();
}, [setAnnotationOffset]); applyOffset(0);
}, [applyOffset, throttledApplyOffset]);
const saveToConfig = useCallback(async () => { const saveToConfig = useCallback(async () => {
if (!config || !event) return; if (!config || !event) return;
@ -143,6 +181,7 @@ export function AnnotationSettingsPane({
max={ANNOTATION_OFFSET_MAX} max={ANNOTATION_OFFSET_MAX}
step={ANNOTATION_OFFSET_STEP} step={ANNOTATION_OFFSET_STEP}
onValueChange={handleSliderChange} onValueChange={handleSliderChange}
onValueCommit={handleSliderCommit}
className="flex-1" className="flex-1"
/> />
<Button <Button

View File

@ -73,7 +73,7 @@ export default function DetailActionsMenu({
} }
return ( return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}> <DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<div className="rounded" role="button"> <div className="rounded" role="button">
<HiDotsHorizontal className="size-4 text-muted-foreground" /> <HiDotsHorizontal className="size-4 text-muted-foreground" />

View File

@ -957,8 +957,9 @@ function ObjectDetailsTab({
toast.success( toast.success(
t("details.item.toast.success.regenerate", { t("details.item.toast.success.regenerate", {
provider: capitalizeAll( provider: capitalizeAll(
config?.genai.provider.replaceAll("_", " ") ?? Object.values(config?.genai ?? {})
t("generativeAI"), .find((agent) => agent?.roles?.includes("descriptions"))
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
), ),
}), }),
{ {
@ -976,8 +977,9 @@ function ObjectDetailsTab({
toast.error( toast.error(
t("details.item.toast.error.regenerate", { t("details.item.toast.error.regenerate", {
provider: capitalizeAll( provider: capitalizeAll(
config?.genai.provider.replaceAll("_", " ") ?? Object.values(config?.genai ?? {})
t("generativeAI"), .find((agent) => agent?.roles?.includes("descriptions"))
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
), ),
errorMessage, errorMessage,
}), }),

View File

@ -1,5 +1,13 @@
import useSWR from "swr"; import useSWR from "swr";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { flushSync } from "react-dom";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { useFullscreen } from "@/hooks/use-fullscreen"; import { useFullscreen } from "@/hooks/use-fullscreen";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
@ -389,7 +397,12 @@ export function TrackingDetails({
// When the pinned timestamp or offset changes, re-seek the video and // When the pinned timestamp or offset changes, re-seek the video and
// explicitly update currentTime so the overlay shows the pinned event's box. // explicitly update currentTime so the overlay shows the pinned event's box.
useEffect(() => { // useLayoutEffect + flushSync force the setCurrentTime commit to land before
// the browser paints, so the overlay never shows a frame where
// annotationOffset has changed but currentTime has not — that mismatch would
// resolve effectiveCurrentTime away from the pinned detect timestamp and
// make the bounding box disappear or jump for one frame.
useLayoutEffect(() => {
const pinned = pinnedDetectTimestampRef.current; const pinned = pinnedDetectTimestampRef.current;
if (!isAnnotationSettingsOpen || pinned == null) return; if (!isAnnotationSettingsOpen || pinned == null) return;
if (!videoRef.current || displaySource !== "video") return; if (!videoRef.current || displaySource !== "video") return;
@ -398,10 +411,9 @@ export function TrackingDetails({
const relativeTime = timestampToVideoTime(targetTimeRecord); const relativeTime = timestampToVideoTime(targetTimeRecord);
videoRef.current.currentTime = relativeTime; videoRef.current.currentTime = relativeTime;
// Explicitly update currentTime state so the overlay's effectiveCurrentTime flushSync(() => {
// resolves back to the pinned detect timestamp: setCurrentTime(targetTimeRecord);
// effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned });
setCurrentTime(targetTimeRecord);
}, [ }, [
isAnnotationSettingsOpen, isAnnotationSettingsOpen,
annotationOffset, annotationOffset,
@ -1204,7 +1216,11 @@ function LifecycleIconRow({
<div className="flex flex-row items-center gap-3"> <div className="flex flex-row items-center gap-3">
<div className="whitespace-nowrap">{formattedEventTimestamp}</div> <div className="whitespace-nowrap">{formattedEventTimestamp}</div>
{isAdmin && (config?.plus?.enabled || item.data.box) && ( {isAdmin && (config?.plus?.enabled || item.data.box) && (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}> <DropdownMenu
modal={false}
open={isOpen}
onOpenChange={setIsOpen}
>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<div className="rounded p-1 pr-2" role="button"> <div className="rounded p-1 pr-2" role="button">
<HiDotsHorizontal className="size-4 text-muted-foreground" /> <HiDotsHorizontal className="size-4 text-muted-foreground" />

View File

@ -126,13 +126,20 @@ export default function DetailStream({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlsExpanded]); }, [controlsExpanded]);
// Re-seek on annotation offset change while settings panel is open // The slider invokes this atomically with setAnnotationOffset (inside the
useEffect(() => { // same flushSync) so currentTime advances in the same React commit as the
const pinned = pinnedDetectTimestampRef.current; // offset. Without this, the overlay would render one frame with the new
if (!controlsExpanded || pinned == null) return; // offset but the old currentTime, briefly resolving effectiveCurrentTime to
const recordTime = pinned + annotationOffset / 1000; // the wrong detect-stream timestamp and making the bounding box vanish or
onSeek(recordTime, false); // jump.
}, [controlsExpanded, annotationOffset, onSeek]); const handleApplyOffset = useCallback(
(newOffset: number) => {
const pinned = pinnedDetectTimestampRef.current;
if (!controlsExpanded || pinned == null) return;
onSeek(pinned + newOffset / 1000, false);
},
[controlsExpanded, onSeek],
);
// Ensure we initialize the active review when reviewItems first arrive. // Ensure we initialize the active review when reviewItems first arrive.
// This helps when the component mounts while the video is already // This helps when the component mounts while the video is already
@ -337,7 +344,7 @@ export default function DetailStream({
</button> </button>
{controlsExpanded && ( {controlsExpanded && (
<div className="space-y-4 px-3 pb-5 pt-2"> <div className="space-y-4 px-3 pb-5 pt-2">
<AnnotationOffsetSlider /> <AnnotationOffsetSlider onApplyOffset={handleApplyOffset} />
<Separator /> <Separator />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -53,6 +53,10 @@ export default function EventMenu({
const handleDebugReplay = useCallback( const handleDebugReplay = useCallback(
(event: Event) => { (event: Event) => {
setIsStarting(true); setIsStarting(true);
const toastId = toast.loading(
t("dialog.starting", { ns: "views/replay" }),
{ position: "top-center" },
);
axios axios
.post("debug_replay/start", { .post("debug_replay/start", {
@ -63,6 +67,7 @@ export default function EventMenu({
.then((response) => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
toast.success(t("dialog.toast.success", { ns: "views/replay" }), { toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
id: toastId,
position: "top-center", position: "top-center",
}); });
navigate("/replay"); navigate("/replay");
@ -78,6 +83,7 @@ export default function EventMenu({
toast.error( toast.error(
t("dialog.toast.alreadyActive", { ns: "views/replay" }), t("dialog.toast.alreadyActive", { ns: "views/replay" }),
{ {
id: toastId,
position: "top-center", position: "top-center",
closeButton: true, closeButton: true,
dismissible: false, dismissible: false,
@ -92,6 +98,7 @@ export default function EventMenu({
); );
} else { } else {
toast.error(t("dialog.toast.error", { error: errorMessage }), { toast.error(t("dialog.toast.error", { error: errorMessage }), {
id: toastId,
position: "top-center", position: "top-center",
}); });
} }
@ -106,7 +113,7 @@ export default function EventMenu({
return ( return (
<> <>
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}> <DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<div className="rounded p-1 pr-2" role="button"> <div className="rounded p-1 pr-2" role="button">
<HiDotsHorizontal className="size-4 text-muted-foreground" /> <HiDotsHorizontal className="size-4 text-muted-foreground" />

View File

@ -28,6 +28,14 @@ export default function useNavigation(
}); });
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const hasChatAgent = useMemo(
() =>
Object.values(config?.genai ?? {}).some((agent) =>
agent?.roles?.includes("chat"),
),
[config?.genai],
);
return useMemo( return useMemo(
() => () =>
[ [
@ -89,9 +97,9 @@ export default function useNavigation(
icon: MdChat, icon: MdChat,
title: "menu.chat", title: "menu.chat",
url: "/chat", url: "/chat",
enabled: isDesktop && isAdmin && config?.genai?.model !== "none", enabled: isDesktop && isAdmin && hasChatAgent,
}, },
] as NavData[], ] as NavData[],
[config?.face_recognition?.enabled, config?.genai?.model, variant, isAdmin], [config?.face_recognition?.enabled, hasChatAgent, variant, isAdmin],
); );
} }

View File

@ -382,6 +382,18 @@ export type AllGroupsStreamingSettings = {
[groupName: string]: GroupStreamingSettings; [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<string, unknown>;
runtime_options?: Record<string, unknown>;
};
export interface FrigateConfig { export interface FrigateConfig {
version: string; version: string;
safe_mode: boolean; safe_mode: boolean;
@ -478,12 +490,7 @@ export interface FrigateConfig {
retry_interval: number; retry_interval: number;
}; };
genai: { genai: Record<string, GenAIAgentConfig>;
provider: string;
base_url?: string;
api_key?: string;
model: string;
};
go2rtc: { go2rtc: {
streams: Record<string, string | string[]>; streams: Record<string, string | string[]>;