mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-07 05:55:27 +03:00
Compare commits
2 Commits
6a2b914b10
...
b6fd86a066
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6fd86a066 | ||
|
|
147cd5cc2b |
@ -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 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).
|
||||
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).
|
||||
|
||||
#### Configuration
|
||||
|
||||
@ -210,7 +210,8 @@ Ollama also supports [cloud models](https://ollama.com/cloud), where your local
|
||||
|
||||
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`)
|
||||
- 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 **Model** to the cloud model name
|
||||
|
||||
</TabItem>
|
||||
@ -223,6 +224,16 @@ 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>
|
||||
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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
|
||||
|
||||
@ -31,6 +31,12 @@ 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 = {
|
||||
@ -39,7 +45,11 @@ class OllamaClient(GenAIClient):
|
||||
}
|
||||
|
||||
try:
|
||||
client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout)
|
||||
client = ApiClient(
|
||||
host=self.genai_config.base_url,
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
# ensure the model is available locally
|
||||
response = client.show(self.genai_config.model)
|
||||
if response.get("error"):
|
||||
@ -166,7 +176,9 @@ class OllamaClient(GenAIClient):
|
||||
return []
|
||||
try:
|
||||
client = ApiClient(
|
||||
host=self.genai_config.base_url, timeout=self.timeout
|
||||
host=self.genai_config.base_url,
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
@ -344,6 +356,7 @@ 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)
|
||||
@ -359,6 +372,7 @@ 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
|
||||
|
||||
@ -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 ""
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -93,6 +93,14 @@ 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(() => {
|
||||
@ -511,7 +519,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<span>{t("menu.classification")}</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
{config?.genai?.model !== "none" && (
|
||||
{hasChatAgent && (
|
||||
<Link to="/chat">
|
||||
<MenuItem
|
||||
className="flex w-full items-center p-2 text-sm"
|
||||
|
||||
@ -90,6 +90,10 @@ 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", {
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { Event } from "@/types/event";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
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 { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@ -19,6 +21,8 @@ import {
|
||||
ANNOTATION_OFFSET_STEP,
|
||||
} from "@/lib/const";
|
||||
|
||||
const SLIDER_DRAG_THROTTLE_MS = 80;
|
||||
|
||||
type AnnotationSettingsPaneProps = {
|
||||
event: Event;
|
||||
annotationOffset: number;
|
||||
@ -38,30 +42,64 @@ export function AnnotationSettingsPane({
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
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),
|
||||
);
|
||||
// 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);
|
||||
});
|
||||
},
|
||||
[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(() => {
|
||||
setAnnotationOffset(0);
|
||||
}, [setAnnotationOffset]);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(0);
|
||||
}, [applyOffset, throttledApplyOffset]);
|
||||
|
||||
const saveToConfig = useCallback(async () => {
|
||||
if (!config || !event) return;
|
||||
@ -143,6 +181,7 @@ export function AnnotationSettingsPane({
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleSliderChange}
|
||||
onValueCommit={handleSliderCommit}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@ -73,7 +73,7 @@ export default function DetailActionsMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@ -957,8 +957,9 @@ function ObjectDetailsTab({
|
||||
toast.success(
|
||||
t("details.item.toast.success.regenerate", {
|
||||
provider: capitalizeAll(
|
||||
config?.genai.provider.replaceAll("_", " ") ??
|
||||
t("generativeAI"),
|
||||
Object.values(config?.genai ?? {})
|
||||
.find((agent) => agent?.roles?.includes("descriptions"))
|
||||
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
|
||||
),
|
||||
}),
|
||||
{
|
||||
@ -976,8 +977,9 @@ function ObjectDetailsTab({
|
||||
toast.error(
|
||||
t("details.item.toast.error.regenerate", {
|
||||
provider: capitalizeAll(
|
||||
config?.genai.provider.replaceAll("_", " ") ??
|
||||
t("generativeAI"),
|
||||
Object.values(config?.genai ?? {})
|
||||
.find((agent) => agent?.roles?.includes("descriptions"))
|
||||
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
|
||||
),
|
||||
errorMessage,
|
||||
}),
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
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 { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { Event } from "@/types/event";
|
||||
@ -389,7 +397,12 @@ 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.
|
||||
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;
|
||||
if (!isAnnotationSettingsOpen || pinned == null) return;
|
||||
if (!videoRef.current || displaySource !== "video") return;
|
||||
@ -398,10 +411,9 @@ export function TrackingDetails({
|
||||
const relativeTime = timestampToVideoTime(targetTimeRecord);
|
||||
videoRef.current.currentTime = relativeTime;
|
||||
|
||||
// Explicitly update currentTime state so the overlay's effectiveCurrentTime
|
||||
// resolves back to the pinned detect timestamp:
|
||||
// effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned
|
||||
setCurrentTime(targetTimeRecord);
|
||||
flushSync(() => {
|
||||
setCurrentTime(targetTimeRecord);
|
||||
});
|
||||
}, [
|
||||
isAnnotationSettingsOpen,
|
||||
annotationOffset,
|
||||
@ -1204,7 +1216,11 @@ 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 open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded p-1 pr-2" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@ -126,13 +126,20 @@ export default function DetailStream({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controlsExpanded]);
|
||||
|
||||
// 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]);
|
||||
// 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],
|
||||
);
|
||||
|
||||
// Ensure we initialize the active review when reviewItems first arrive.
|
||||
// This helps when the component mounts while the video is already
|
||||
@ -337,7 +344,7 @@ export default function DetailStream({
|
||||
</button>
|
||||
{controlsExpanded && (
|
||||
<div className="space-y-4 px-3 pb-5 pt-2">
|
||||
<AnnotationOffsetSlider />
|
||||
<AnnotationOffsetSlider onApplyOffset={handleApplyOffset} />
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded p-1 pr-2" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, unknown>;
|
||||
runtime_options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, GenAIAgentConfig>;
|
||||
|
||||
go2rtc: {
|
||||
streams: Record<string, string | string[]>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user