Compare commits

..

No commits in common. "b6fd86a066140651b917696adbe32a9dd60bde8e" and "6a2b914b10f980e733bfff569f9076c3fad5e12a" have entirely different histories.

18 changed files with 86 additions and 402 deletions

View File

@ -201,7 +201,7 @@ Cloud Generative AI providers require an active internet connection to send imag
### Ollama Cloud
Ollama also supports [cloud models](https://ollama.com/cloud), where model inference is performed in the cloud. You can connect directly to Ollama Cloud by setting `base_url` to `https://ollama.com` and providing an API key. Alternatively, you can run Ollama locally and use a cloud model name so your local instance forwards requests to the cloud. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
#### Configuration
@ -210,8 +210,7 @@ Ollama also supports [cloud models](https://ollama.com/cloud), where model infer
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
- Set **Provider** to `ollama`
- Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`) or `https://ollama.com` for direct cloud inference
- Set **API key** if required by your endpoint (e.g., when using `https://ollama.com`)
- Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`)
- Set **Model** to the cloud model name
</TabItem>
@ -224,16 +223,6 @@ genai:
model: cloud-model-name
```
or when using Ollama Cloud directly
```yaml
genai:
provider: ollama
base_url: https://ollama.com
model: cloud-model-name
api_key: your-api-key
```
</TabItem>
</ConfigTabs>

View File

@ -429,10 +429,7 @@ class WebPushClient(Communicator):
else:
title = base_title
if payload["after"]["data"]["metadata"].get("shortSummary"):
message = payload["after"]["data"]["metadata"]["shortSummary"]
else:
message = f"Detected on {camera_name}"
message = payload["after"]["data"]["metadata"]["shortSummary"]
else:
zone_names = payload["after"]["data"]["zones"]
formatted_zone_names = []

View File

@ -1073,6 +1073,10 @@ 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
@ -1088,6 +1092,9 @@ 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
@ -1353,8 +1360,8 @@ class LicensePlateProcessingMixin:
)
# check that license plate is valid
# quadruple the value because we've doubled both dimensions of the car
if license_plate_area < self.config.cameras[camera].lpr.min_area * 4:
# double the value because we've doubled the size of the car
if license_plate_area < self.config.cameras[camera].lpr.min_area * 2:
logger.debug(f"{camera}: License plate is less than min_area")
return
@ -1458,7 +1465,6 @@ 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

View File

@ -31,12 +31,6 @@ class OllamaClient(GenAIClient):
provider: ApiClient | None
provider_options: dict[str, Any]
def _auth_headers(self) -> dict | None:
if self.genai_config.api_key:
return {"Authorization": "Bearer " + self.genai_config.api_key}
return None
def _init_provider(self) -> ApiClient | None:
"""Initialize the client."""
self.provider_options = {
@ -45,11 +39,7 @@ class OllamaClient(GenAIClient):
}
try:
client = ApiClient(
host=self.genai_config.base_url,
timeout=self.timeout,
headers=self._auth_headers(),
)
client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout)
# ensure the model is available locally
response = client.show(self.genai_config.model)
if response.get("error"):
@ -176,9 +166,7 @@ class OllamaClient(GenAIClient):
return []
try:
client = ApiClient(
host=self.genai_config.base_url,
timeout=self.timeout,
headers=self._auth_headers(),
host=self.genai_config.base_url, timeout=self.timeout
)
except Exception:
return []
@ -356,7 +344,6 @@ class OllamaClient(GenAIClient):
async_client = OllamaAsyncClient(
host=self.genai_config.base_url,
timeout=self.timeout,
headers=self._auth_headers(),
)
response = await async_client.chat(**request_params)
result = self._message_from_response(response)
@ -372,7 +359,6 @@ class OllamaClient(GenAIClient):
async_client = OllamaAsyncClient(
host=self.genai_config.base_url,
timeout=self.timeout,
headers=self._auth_headers(),
)
content_parts: list[str] = []
final_message: dict[str, Any] | None = None

View File

@ -13,7 +13,6 @@ 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
@ -345,19 +344,7 @@ class RecordingExporter(threading.Thread):
return proc.returncode, "".join(captured)
def get_datetime_from_timestamp(self, timestamp: int) -> str:
# 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 in iso format
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def _chapter_metadata_path(self) -> str:
@ -551,18 +538,12 @@ 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:
@ -571,9 +552,6 @@ 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 ""

View File

@ -1,9 +1,6 @@
"""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
@ -366,121 +363,6 @@ 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()

View File

@ -69,18 +69,17 @@ test.describe("Navigation — conditional items @critical", () => {
).toBeVisible();
});
test("/chat is hidden when no agent has the chat role (desktop)", async ({
test("/chat is hidden when genai.model is none (desktop)", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop sidebar");
await frigateApp.installDefaults({
config: {
genai: {
descriptions_only: {
provider: "ollama",
model: "llava",
roles: ["descriptions"],
},
enabled: false,
provider: "ollama",
model: "none",
base_url: "",
},
},
});
@ -90,20 +89,12 @@ test.describe("Navigation — conditional items @critical", () => {
).toHaveCount(0);
});
test("/chat is visible when an agent has the chat role (desktop)", async ({
test("/chat is visible when genai.model is set (desktop)", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop sidebar");
await frigateApp.installDefaults({
config: {
genai: {
chat_agent: {
provider: "ollama",
model: "llava",
roles: ["chat"],
},
},
},
config: { genai: { enabled: true, model: "llava" } },
});
await frigateApp.goto("/");
await expect(

View File

@ -93,14 +93,6 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
useSWR<ProfilesApiResponse>("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(() => {
@ -519,7 +511,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>{t("menu.classification")}</span>
</MenuItem>
</Link>
{hasChatAgent && (
{config?.genai?.model !== "none" && (
<Link to="/chat">
<MenuItem
className="flex w-full items-center p-2 text-sm"

View File

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

View File

@ -1,6 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { flushSync } from "react-dom";
import { throttle } from "lodash";
import { useCallback, useState } from "react";
import { Slider } from "@/components/ui/slider";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
@ -21,21 +19,11 @@ 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,
onApplyOffset,
}: Props) {
export default function AnnotationOffsetSlider({ className }: Props) {
const { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
const isAdmin = useIsAdmin();
const { getLocaleDocUrl } = useDocDomain();
@ -43,62 +31,31 @@ export default function AnnotationOffsetSlider({
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;
throttledApplyOffset(values[0]);
const valueMs = values[0];
setAnnotationOffset(valueMs);
},
[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],
[setAnnotationOffset],
);
const stepOffset = useCallback(
(delta: number) => {
const next = Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
);
throttledApplyOffset.cancel();
applyOffset(next);
setAnnotationOffset((prev) => {
const next = prev + delta;
return Math.max(
ANNOTATION_OFFSET_MIN,
Math.min(ANNOTATION_OFFSET_MAX, next),
);
});
},
[annotationOffset, applyOffset, throttledApplyOffset],
[setAnnotationOffset],
);
const reset = useCallback(() => {
throttledApplyOffset.cancel();
applyOffset(0);
}, [applyOffset, throttledApplyOffset]);
setAnnotationOffset(0);
}, [setAnnotationOffset]);
const save = useCallback(async () => {
setIsSaving(true);
@ -173,7 +130,6 @@ export default function AnnotationOffsetSlider({
max={ANNOTATION_OFFSET_MAX}
step={ANNOTATION_OFFSET_STEP}
onValueChange={handleChange}
onValueCommit={handleCommit}
/>
</div>
<Button

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -53,10 +53,6 @@ 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", {
@ -67,7 +63,6 @@ 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");
@ -83,7 +78,6 @@ export default function EventMenu({
toast.error(
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
{
id: toastId,
position: "top-center",
closeButton: true,
dismissible: false,
@ -98,7 +92,6 @@ export default function EventMenu({
);
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
id: toastId,
position: "top-center",
});
}
@ -113,7 +106,7 @@ export default function EventMenu({
return (
<>
<span tabIndex={0} className="sr-only" />
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger>
<div className="rounded p-1 pr-2" role="button">
<HiDotsHorizontal className="size-4 text-muted-foreground" />

View File

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

View File

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