mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-04 04:27:42 +03:00
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:
parent
6a2b914b10
commit
147cd5cc2b
@ -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 = []
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 ""
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[]>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user