From da8e7662989a3da7ed5c1cb4ce10fb585f4fade5 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 10:11:09 -0500
Subject: [PATCH 01/20] start audio transcription post processor when enabled
on any camera
---
frigate/embeddings/maintainer.py | 2 +-
frigate/events/audio.py | 7 +++++--
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py
index 96a44a8c6c..48c5cdd79b 100644
--- a/frigate/embeddings/maintainer.py
+++ b/frigate/embeddings/maintainer.py
@@ -232,7 +232,7 @@ class EmbeddingMaintainer(threading.Thread):
)
)
- if self.config.audio_transcription.enabled and any(
+ if any(
c.enabled_in_config and c.audio_transcription.enabled
for c in self.config.cameras.values()
):
diff --git a/frigate/events/audio.py b/frigate/events/audio.py
index c5e35a8d52..b90b795778 100644
--- a/frigate/events/audio.py
+++ b/frigate/events/audio.py
@@ -100,7 +100,10 @@ class AudioProcessor(FrigateProcess):
threading.current_thread().name = "process:audio_manager"
- if self.config.audio_transcription.enabled:
+ if any(
+ c.enabled_in_config and c.audio_transcription.enabled
+ for c in self.config.cameras.values()
+ ):
self.transcription_model_runner: AudioTranscriptionModelRunner | None = (
AudioTranscriptionModelRunner(
self.config.audio_transcription.device or "AUTO",
@@ -206,7 +209,7 @@ class AudioEventMaintainer(threading.Thread):
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio.value)
if (
- self.config.audio_transcription.enabled
+ self.camera_config.audio_transcription.enabled
and self.audio_transcription_model_runner is not None
):
# init the transcription processor for this camera
From abac6d5253240c8459c0ec64457982ccf70d8145 Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Mon, 18 May 2026 10:23:40 -0600
Subject: [PATCH 02/20] Fetch embed key whenever an error occurs in case the
llama server was restarted
---
frigate/genai/llama_cpp.py | 114 ++++++++++++++++++++++++++-----------
1 file changed, 82 insertions(+), 32 deletions(-)
diff --git a/frigate/genai/llama_cpp.py b/frigate/genai/llama_cpp.py
index c935207bfe..3b478e8021 100644
--- a/frigate/genai/llama_cpp.py
+++ b/frigate/genai/llama_cpp.py
@@ -75,6 +75,29 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None:
return args[idx + 1]
+def _fetch_llama_props(base_url: str, model: str) -> dict[str, Any]:
+ """Fetch /props from a llama.cpp server, with llama-swap fallback.
+
+ Raises the underlying RequestException if both endpoints fail; callers
+ decide how to surface the failure.
+ """
+ try:
+ response = requests.get(
+ f"{base_url}/props",
+ params={"model": model},
+ timeout=10,
+ )
+ response.raise_for_status()
+ return response.json()
+ except Exception:
+ response = requests.get(
+ f"{base_url}/upstream/{model}/props",
+ timeout=10,
+ )
+ response.raise_for_status()
+ return response.json()
+
+
def _to_jpeg(img_bytes: bytes) -> bytes | None:
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
try:
@@ -239,21 +262,7 @@ class LlamaCppClient(GenAIClient):
info["supports_tools"] = True
try:
- try:
- response = requests.get(
- f"{base_url}/props",
- params={"model": configured_model},
- timeout=10,
- )
- response.raise_for_status()
- props = response.json()
- except Exception:
- response = requests.get(
- f"{base_url}/upstream/{configured_model}/props",
- timeout=10,
- )
- response.raise_for_status()
- props = response.json()
+ props = _fetch_llama_props(base_url, configured_model)
if info["context_size"] is None:
default_settings = props.get("default_generation_settings", {})
@@ -559,6 +568,31 @@ class LlamaCppClient(GenAIClient):
)
return result if result else None
+ def _refresh_media_marker(self) -> bool:
+ """Re-fetch /props and update the cached media marker if it changed.
+
+ The server randomizes the marker per startup (unless LLAMA_MEDIA_MARKER
+ is set), so a stale marker indicates a restart. Returns True iff the
+ marker was updated to a new value — used to gate a one-shot retry of
+ a failed embeddings request.
+ """
+ if self.provider is None:
+ return False
+ try:
+ props = _fetch_llama_props(self.provider, self.genai_config.model)
+ except Exception as e:
+ logger.warning("Failed to refresh llama.cpp media marker: %s", e)
+ return False
+
+ marker = props.get("media_marker")
+
+ if not isinstance(marker, str) or not marker or marker == self._media_marker:
+ return False
+
+ logger.info("llama.cpp media marker changed (server restart); refreshed")
+ self._media_marker = marker
+ return True
+
def embed(
self,
texts: list[str] | None = None,
@@ -583,30 +617,46 @@ class LlamaCppClient(GenAIClient):
EMBEDDING_DIM = 768
- content = []
- for text in texts:
- content.append({"prompt_string": text})
+ encoded_images: list[str] = []
for img in images:
# llama.cpp uses STB which does not support WebP; convert to JPEG
jpeg_bytes = _to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img
- encoded = base64.b64encode(to_encode).decode("utf-8")
- # prompt_string must contain the server's media marker placeholder.
- # The marker is randomized per server startup (read from /props).
- content.append(
- {
- "prompt_string": f"{self._media_marker}\n",
- "multimodal_data": [encoded], # type: ignore[dict-item]
- }
+ encoded_images.append(base64.b64encode(to_encode).decode("utf-8"))
+
+ def build_content() -> list[dict[str, Any]]:
+ # prompt_string must contain the server's media marker placeholder
+ # for each image. The marker is randomized per server startup.
+ content: list[dict[str, Any]] = []
+ for text in texts:
+ content.append({"prompt_string": text})
+ for encoded in encoded_images:
+ content.append(
+ {
+ "prompt_string": f"{self._media_marker}\n",
+ "multimodal_data": [encoded],
+ }
+ )
+ return content
+
+ def post_embeddings() -> requests.Response:
+ return requests.post(
+ f"{self.provider}/embeddings",
+ json={"model": self.genai_config.model, "content": build_content()},
+ timeout=self.timeout,
)
try:
- response = requests.post(
- f"{self.provider}/embeddings",
- json={"model": self.genai_config.model, "content": content},
- timeout=self.timeout,
- )
- response.raise_for_status()
+ try:
+ response = post_embeddings()
+ response.raise_for_status()
+ except requests.exceptions.RequestException:
+ # The server may have restarted with a new media marker.
+ # Refresh from /props; only retry if the marker actually changed.
+ if not encoded_images or not self._refresh_media_marker():
+ raise
+ response = post_embeddings()
+ response.raise_for_status()
result = response.json()
items = result.get("data", result) if isinstance(result, dict) else result
From 3df3d4482c2ad4c228e1058b32dcb2248be3133b Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 12:59:04 -0500
Subject: [PATCH 03/20] mypy
---
frigate/genai/llama_cpp.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/frigate/genai/llama_cpp.py b/frigate/genai/llama_cpp.py
index 3b478e8021..86db201288 100644
--- a/frigate/genai/llama_cpp.py
+++ b/frigate/genai/llama_cpp.py
@@ -4,7 +4,7 @@ import base64
import io
import json
import logging
-from typing import Any, AsyncGenerator, Optional
+from typing import Any, AsyncGenerator, Optional, cast
import httpx
import numpy as np
@@ -88,14 +88,14 @@ def _fetch_llama_props(base_url: str, model: str) -> dict[str, Any]:
timeout=10,
)
response.raise_for_status()
- return response.json()
+ return cast(dict[str, Any], response.json())
except Exception:
response = requests.get(
f"{base_url}/upstream/{model}/props",
timeout=10,
)
response.raise_for_status()
- return response.json()
+ return cast(dict[str, Any], response.json())
def _to_jpeg(img_bytes: bytes) -> bytes | None:
From e348c43977726f17882efa14be738c734f2d3669 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 13:02:20 -0500
Subject: [PATCH 04/20] add tooltips for colored dots in settings menu
---
web/public/locales/en/views/settings.json | 5 ++
web/src/pages/Settings.tsx | 61 +++++++++++++++++++----
2 files changed, 57 insertions(+), 9 deletions(-)
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index dd93a62ec0..1391e69ede 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -40,6 +40,11 @@
"profilePrefix": "{{profile}} profile: {{fields}}"
}
},
+ "menuDot": {
+ "overrideGlobal": "This section overrides the global configuration",
+ "overrideProfile": "This section is overridden by the {{profile}} profile",
+ "unsaved": "This section has unsaved changes"
+ },
"menu": {
"general": "General",
"globalConfig": "Global configuration",
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx
index b916e84609..fb1d4a37f7 100644
--- a/web/src/pages/Settings.tsx
+++ b/web/src/pages/Settings.tsx
@@ -103,6 +103,12 @@ import SaveAllPreviewPopover, {
type SaveAllPreviewItem,
} from "@/components/overlay/detail/SaveAllPreviewPopover";
import { useRestart } from "@/api/ws";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { TooltipPortal } from "@radix-ui/react-tooltip";
const allSettingsViews = [
"uiSettings",
@@ -1386,10 +1392,20 @@ export default function Settings() {
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
const showUnsavedDot = status?.hasChanges;
- const dotColor =
- status?.overrideSource === "profile" && activeProfileColor
- ? activeProfileColor.dot
- : "bg-selected";
+ const isProfileOverride =
+ status?.overrideSource === "profile" && activeProfileColor;
+ const dotColor = isProfileOverride
+ ? activeProfileColor.dot
+ : "bg-selected";
+
+ const overrideTooltip = isProfileOverride
+ ? t("menuDot.overrideProfile", {
+ profile: activeEditingProfile
+ ? (profileFriendlyNames.get(activeEditingProfile) ??
+ activeEditingProfile)
+ : "",
+ })
+ : t("menuDot.overrideGlobal");
return (
@@ -1399,19 +1415,46 @@ export default function Settings() {
{(showOverrideDot || showUnsavedDot) && (
{showOverrideDot && (
-
+
+
+
+
+
+
+ {overrideTooltip}
+
+
+
)}
{showUnsavedDot && (
-
+
+
+
+
+
+
+ {t("menuDot.unsaved")}
+
+
+
)}
)}
);
},
- [sectionStatusByKey, t, activeProfileColor],
+ [
+ sectionStatusByKey,
+ t,
+ activeProfileColor,
+ activeEditingProfile,
+ profileFriendlyNames,
+ ],
);
if (isMobile) {
From 434c17ec7757cadc5f3ba2886d17615e901ddc0b Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 13:44:38 -0500
Subject: [PATCH 05/20] add ability to reorder cameras from management pane
---
web/public/locales/en/views/settings.json | 8 +-
.../views/settings/CameraManagementView.tsx | 147 +++++++++++++++---
2 files changed, 134 insertions(+), 21 deletions(-)
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index 1391e69ede..27e2f01ce9 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -477,10 +477,11 @@
"streams": {
"title": "Enable / Disable Cameras",
"enableLabel": "Enabled cameras",
- "enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams.",
+ "enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.
Note: This does not disable go2rtc restreams.
Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.",
"disableLabel": "Disabled cameras",
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
+ "reorderHandle": "Drag to reorder",
"friendlyName": {
"edit": "Edit camera display name",
"title": "Edit Display Name",
@@ -1685,6 +1686,11 @@
"objects": "Objects",
"motion": "Motion",
"continuous": "Continuous"
+ },
+ "cameraOrder": {
+ "label": "Camera order",
+ "description": "Drag cameras to set their order in the Birdseye layout.",
+ "reorderHandle": "Drag to reorder"
}
},
"retainMode": {
diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx
index 6233b232c6..08f6e9d8e4 100644
--- a/web/src/views/settings/CameraManagementView.tsx
+++ b/web/src/views/settings/CameraManagementView.tsx
@@ -1,5 +1,5 @@
import Heading from "@/components/ui/heading";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CONTROL_COLUMN_CLASS_NAME,
SettingsGroupCard,
@@ -14,7 +14,14 @@ import { useTranslation } from "react-i18next";
import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
-import { LuExternalLink, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
+import {
+ LuExternalLink,
+ LuGripVertical,
+ LuPencil,
+ LuPlus,
+ LuTrash2,
+} from "react-icons/lu";
+import { Reorder, useDragControls } from "framer-motion";
import { IoMdArrowRoundBack } from "react-icons/io";
import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
@@ -54,7 +61,7 @@ export default function CameraManagementView({
setUnsavedChanges,
profileState,
}: CameraManagementViewProps) {
- const { t } = useTranslation(["views/settings"]);
+ const { t } = useTranslation(["views/settings", "common"]);
const { data: config, mutate: updateConfig } =
useSWR("config");
@@ -72,16 +79,74 @@ export default function CameraManagementView({
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
const { send: sendRestart } = useRestart();
- // List of cameras for dropdown
const enabledCameras = useMemo(() => {
if (config) {
return Object.keys(config.cameras)
.filter((camera) => config.cameras[camera].enabled_in_config)
- .sort();
+ .sort((a, b) => {
+ const orderA = config.cameras[a].ui?.order ?? 0;
+ const orderB = config.cameras[b].ui?.order ?? 0;
+ if (orderA !== orderB) return orderA - orderB;
+ return a.localeCompare(b);
+ });
}
return [];
}, [config]);
+ // Diverges from config during a drag and while the save is in flight.
+ const [orderedCameras, setOrderedCameras] =
+ useState(enabledCameras);
+ const orderedCamerasRef = useRef(orderedCameras);
+ useEffect(() => {
+ orderedCamerasRef.current = orderedCameras;
+ }, [orderedCameras]);
+
+ useEffect(() => {
+ setOrderedCameras((prev) => {
+ if (
+ prev.length === enabledCameras.length &&
+ prev.every((cam, i) => cam === enabledCameras[i])
+ ) {
+ return prev;
+ }
+ return enabledCameras;
+ });
+ }, [enabledCameras]);
+
+ const handleReorderDragEnd = useCallback(async () => {
+ const current = orderedCamerasRef.current;
+ if (
+ current.length === enabledCameras.length &&
+ current.every((cam, i) => cam === enabledCameras[i])
+ ) {
+ return;
+ }
+
+ const cameraUpdates: Record = {};
+ current.forEach((cam, i) => {
+ cameraUpdates[cam] = { ui: { order: i * 10 } };
+ });
+
+ try {
+ await axios.put("config/set", {
+ requires_restart: 0,
+ config_data: { cameras: cameraUpdates },
+ });
+ await updateConfig();
+ } catch (error) {
+ setOrderedCameras(enabledCameras);
+ const errorMessage =
+ axios.isAxiosError(error) &&
+ (error.response?.data?.message || error.response?.data?.detail)
+ ? error.response?.data?.message || error.response?.data?.detail
+ : t("toast.save.error.noMessage", { ns: "common" });
+
+ toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
+ position: "top-center",
+ });
+ }
+ }, [enabledCameras, updateConfig, t]);
+
const disabledCameras = useMemo(() => {
if (config) {
return Object.keys(config.cameras)
@@ -173,23 +238,22 @@ export default function CameraManagementView({
-
- {enabledCameras.map((camera) => (
-
+ {orderedCameras.map((camera) => (
+
-
-
-
-
-
-
+ camera={camera}
+ onConfigChanged={updateConfig}
+ onDragEnd={handleReorderDragEnd}
+ />
))}
-
+
cameraManagement.streams.enableDesc
@@ -309,6 +373,49 @@ export default function CameraManagementView({
);
}
+type EnabledCameraRowProps = {
+ camera: string;
+ onConfigChanged: () => Promise;
+ onDragEnd: () => void;
+};
+
+function EnabledCameraRow({
+ camera,
+ onConfigChanged,
+ onDragEnd,
+}: EnabledCameraRowProps) {
+ const { t } = useTranslation(["views/settings"]);
+ const controls = useDragControls();
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
type CameraEnableSwitchProps = {
cameraName: string;
};
From be8c81e1732d4e2a1163b7ef1b16dcac9e46be26 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 14:03:43 -0500
Subject: [PATCH 06/20] add ability to reorder birdseye
---
web/public/locales/en/views/settings.json | 4 +-
.../config-form/section-configs/birdseye.ts | 1 +
.../sectionExtras/BirdseyeCameraReorder.tsx | 213 ++++++++++++++++++
.../config-form/sectionExtras/registry.ts | 4 +
4 files changed, 221 insertions(+), 1 deletion(-)
create mode 100644 web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index 27e2f01ce9..edfbb19360 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -1690,7 +1690,9 @@
"cameraOrder": {
"label": "Camera order",
"description": "Drag cameras to set their order in the Birdseye layout.",
- "reorderHandle": "Drag to reorder"
+ "reorderHandle": "Drag to reorder",
+ "saving": "Saving…",
+ "saved": "Saved"
}
},
"retainMode": {
diff --git a/web/src/components/config-form/section-configs/birdseye.ts b/web/src/components/config-form/section-configs/birdseye.ts
index 26e3d2ec8c..c22e006554 100644
--- a/web/src/components/config-form/section-configs/birdseye.ts
+++ b/web/src/components/config-form/section-configs/birdseye.ts
@@ -56,6 +56,7 @@ const birdseye: SectionConfigOverrides = {
uiSchema: {
mode: {
"ui:size": "xs",
+ "ui:after": { render: "BirdseyeCameraReorder" },
},
},
},
diff --git a/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx b/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx
new file mode 100644
index 0000000000..b481e9a86f
--- /dev/null
+++ b/web/src/components/config-form/sectionExtras/BirdseyeCameraReorder.tsx
@@ -0,0 +1,213 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import axios from "axios";
+import { toast } from "sonner";
+import useSWR from "swr";
+import { Reorder, useDragControls } from "framer-motion";
+import { LuCheck, LuGripVertical } from "react-icons/lu";
+import { SplitCardRow } from "@/components/card/SettingsGroupCard";
+import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
+import { FrigateConfig } from "@/types/frigateConfig";
+import { cn } from "@/lib/utils";
+import type { SectionRendererProps } from "./registry";
+
+const SAVED_INDICATOR_MS = 1500;
+
+type SaveStatus = "idle" | "saving" | "saved";
+
+export default function BirdseyeCameraReorder({
+ formContext,
+}: SectionRendererProps) {
+ const { t } = useTranslation(["views/settings", "common"]);
+ const { data: config, mutate: updateConfig } =
+ useSWR("config");
+
+ const birdseyeCameras = useMemo(() => {
+ if (!config) return [];
+ return Object.keys(config.cameras)
+ .filter(
+ (name) =>
+ config.cameras[name].enabled_in_config &&
+ config.cameras[name].birdseye?.enabled !== false,
+ )
+ .sort((a, b) => {
+ const orderA = config.cameras[a].birdseye?.order ?? 0;
+ const orderB = config.cameras[b].birdseye?.order ?? 0;
+ if (orderA !== orderB) return orderA - orderB;
+ return a.localeCompare(b);
+ });
+ }, [config]);
+
+ const [orderedCameras, setOrderedCameras] =
+ useState(birdseyeCameras);
+ const orderedCamerasRef = useRef(orderedCameras);
+ useEffect(() => {
+ orderedCamerasRef.current = orderedCameras;
+ }, [orderedCameras]);
+
+ useEffect(() => {
+ setOrderedCameras((prev) => {
+ if (
+ prev.length === birdseyeCameras.length &&
+ prev.every((cam, i) => cam === birdseyeCameras[i])
+ ) {
+ return prev;
+ }
+ return birdseyeCameras;
+ });
+ }, [birdseyeCameras]);
+
+ const [saveStatus, setSaveStatus] = useState("idle");
+ const savedResetTimerRef = useRef | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (savedResetTimerRef.current) {
+ clearTimeout(savedResetTimerRef.current);
+ }
+ };
+ }, []);
+
+ const handleDragEnd = useCallback(async () => {
+ const current = orderedCamerasRef.current;
+ if (
+ current.length === birdseyeCameras.length &&
+ current.every((cam, i) => cam === birdseyeCameras[i])
+ ) {
+ return;
+ }
+
+ const cameraUpdates: Record = {};
+ current.forEach((cam, i) => {
+ cameraUpdates[cam] = { birdseye: { order: i * 10 } };
+ });
+
+ if (savedResetTimerRef.current) {
+ clearTimeout(savedResetTimerRef.current);
+ savedResetTimerRef.current = null;
+ }
+ setSaveStatus("saving");
+
+ try {
+ await axios.put("config/set", {
+ requires_restart: 0,
+ config_data: { cameras: cameraUpdates },
+ });
+ await updateConfig();
+ setSaveStatus("saved");
+ savedResetTimerRef.current = setTimeout(() => {
+ setSaveStatus("idle");
+ savedResetTimerRef.current = null;
+ }, SAVED_INDICATOR_MS);
+ } catch (error) {
+ setOrderedCameras(birdseyeCameras);
+ setSaveStatus("idle");
+ const errorMessage =
+ axios.isAxiosError(error) &&
+ (error.response?.data?.message || error.response?.data?.detail)
+ ? error.response?.data?.message || error.response?.data?.detail
+ : t("toast.save.error.noMessage", { ns: "common" });
+
+ toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
+ position: "top-center",
+ });
+ }
+ }, [birdseyeCameras, updateConfig, t]);
+
+ if (formContext?.level && formContext.level !== "global") {
+ return null;
+ }
+
+ if (!config || birdseyeCameras.length < 2) {
+ return null;
+ }
+
+ return (
+
+
+ {orderedCameras.map((camera) => (
+
+ ))}
+
+
+
+ }
+ />
+ );
+}
+
+type SaveStatusIndicatorProps = {
+ status: SaveStatus;
+};
+
+function SaveStatusIndicator({ status }: SaveStatusIndicatorProps) {
+ const { t } = useTranslation(["views/settings"]);
+ return (
+
+ {status === "saving" && (
+
+ {t("birdseye.cameraOrder.saving")}
+
+ )}
+ {status === "saved" && (
+
+
+ {t("birdseye.cameraOrder.saved")}
+
+ )}
+
+ );
+}
+
+type BirdseyeCameraRowProps = {
+ camera: string;
+ onDragEnd: () => void;
+};
+
+function BirdseyeCameraRow({ camera, onDragEnd }: BirdseyeCameraRowProps) {
+ const { t } = useTranslation(["views/settings"]);
+ const controls = useDragControls();
+
+ return (
+
+
+
+
+ );
+}
diff --git a/web/src/components/config-form/sectionExtras/registry.ts b/web/src/components/config-form/sectionExtras/registry.ts
index 08e3dd86a8..26cd48dbdb 100644
--- a/web/src/components/config-form/sectionExtras/registry.ts
+++ b/web/src/components/config-form/sectionExtras/registry.ts
@@ -3,6 +3,7 @@ import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
import ProxyRoleMap from "./ProxyRoleMap";
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
+import BirdseyeCameraReorder from "./BirdseyeCameraReorder";
import type { ConfigFormContext } from "@/types/configForm";
// Props that will be injected into all section renderers
@@ -52,6 +53,9 @@ export const sectionRenderers: SectionRenderers = {
notifications: {
NotificationsSettingsExtras,
},
+ birdseye: {
+ BirdseyeCameraReorder,
+ },
};
export default sectionRenderers;
From e33da68fff411cbbfa28ae4977db829e1624d01e Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 14:04:55 -0500
Subject: [PATCH 07/20] add reordering save text to camera management view
---
web/public/locales/en/views/settings.json | 2 +
.../views/settings/CameraManagementView.tsx | 98 ++++++++++++++++---
2 files changed, 84 insertions(+), 16 deletions(-)
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index edfbb19360..05e272f76f 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -482,6 +482,8 @@
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
"reorderHandle": "Drag to reorder",
+ "saving": "Saving…",
+ "saved": "Saved",
"friendlyName": {
"edit": "Edit camera display name",
"title": "Edit Display Name",
diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx
index 08f6e9d8e4..83f2111c6c 100644
--- a/web/src/views/settings/CameraManagementView.tsx
+++ b/web/src/views/settings/CameraManagementView.tsx
@@ -15,6 +15,7 @@ import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
import {
+ LuCheck,
LuExternalLink,
LuGripVertical,
LuPencil,
@@ -52,6 +53,10 @@ import {
SelectValue,
} from "@/components/ui/select";
+const REORDER_SAVED_INDICATOR_MS = 1500;
+
+type ReorderSaveStatus = "idle" | "saving" | "saved";
+
type CameraManagementViewProps = {
setUnsavedChanges: React.Dispatch>;
profileState?: ProfileState;
@@ -113,6 +118,19 @@ export default function CameraManagementView({
});
}, [enabledCameras]);
+ const [reorderSaveStatus, setReorderSaveStatus] =
+ useState("idle");
+ const reorderSavedTimerRef = useRef | null>(
+ null,
+ );
+ useEffect(() => {
+ return () => {
+ if (reorderSavedTimerRef.current) {
+ clearTimeout(reorderSavedTimerRef.current);
+ }
+ };
+ }, []);
+
const handleReorderDragEnd = useCallback(async () => {
const current = orderedCamerasRef.current;
if (
@@ -127,14 +145,26 @@ export default function CameraManagementView({
cameraUpdates[cam] = { ui: { order: i * 10 } };
});
+ if (reorderSavedTimerRef.current) {
+ clearTimeout(reorderSavedTimerRef.current);
+ reorderSavedTimerRef.current = null;
+ }
+ setReorderSaveStatus("saving");
+
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: { cameras: cameraUpdates },
});
await updateConfig();
+ setReorderSaveStatus("saved");
+ reorderSavedTimerRef.current = setTimeout(() => {
+ setReorderSaveStatus("idle");
+ reorderSavedTimerRef.current = null;
+ }, REORDER_SAVED_INDICATOR_MS);
} catch (error) {
setOrderedCameras(enabledCameras);
+ setReorderSaveStatus("idle");
const errorMessage =
axios.isAxiosError(error) &&
(error.response?.data?.message || error.response?.data?.detail)
@@ -238,22 +268,27 @@ export default function CameraManagementView({
-
- {orderedCameras.map((camera) => (
-
- ))}
-
+
+
+ {orderedCameras.map((camera) => (
+
+ ))}
+
+
+
cameraManagement.streams.enableDesc
@@ -373,6 +408,37 @@ export default function CameraManagementView({
);
}
+type ReorderSaveStatusIndicatorProps = {
+ status: ReorderSaveStatus;
+};
+
+function ReorderSaveStatusIndicator({
+ status,
+}: ReorderSaveStatusIndicatorProps) {
+ const { t } = useTranslation(["views/settings"]);
+ return (
+
+ {status === "saving" && (
+
+ {t("cameraManagement.streams.saving")}
+
+ )}
+ {status === "saved" && (
+
+
+ {t("cameraManagement.streams.saved")}
+
+ )}
+
+ );
+}
+
type EnabledCameraRowProps = {
camera: string;
onConfigChanged: () => Promise;
From 278c0634910b6ce090883f04bb53b943c5958071 Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Mon, 18 May 2026 11:56:28 -0600
Subject: [PATCH 08/20] Include NPU in latency performance hint
---
frigate/detectors/detection_runners.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frigate/detectors/detection_runners.py b/frigate/detectors/detection_runners.py
index 39ebbf5783..abfe96fb91 100644
--- a/frigate/detectors/detection_runners.py
+++ b/frigate/detectors/detection_runners.py
@@ -310,7 +310,7 @@ class OpenVINOModelRunner(BaseModelRunner):
# Apply performance optimization
self.ov_core.set_property(device, {"PERF_COUNT": "NO"})
- if device in ["GPU", "AUTO"]:
+ if device in ["GPU", "AUTO", "NPU"]:
self.ov_core.set_property(device, {"PERFORMANCE_HINT": "LATENCY"})
# Compile model
From 40d76a253662fc93159579503cc23cc9375e7ca2 Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Mon, 18 May 2026 11:59:13 -0600
Subject: [PATCH 09/20] Implement turbo for NPU on object detection
---
frigate/detectors/detection_runners.py | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/frigate/detectors/detection_runners.py b/frigate/detectors/detection_runners.py
index abfe96fb91..c89cd8b44f 100644
--- a/frigate/detectors/detection_runners.py
+++ b/frigate/detectors/detection_runners.py
@@ -282,6 +282,13 @@ class OpenVINOModelRunner(BaseModelRunner):
EnrichmentModelTypeEnum.arcface.value,
]
+ @staticmethod
+ def is_detection_model(model_type: str) -> bool:
+ # Import here to avoid circular imports
+ from frigate.detectors.detector_config import ModelTypeEnum
+
+ return model_type in [m.value for m in ModelTypeEnum]
+
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
self.model_path = model_path
self.device = device
@@ -313,6 +320,12 @@ class OpenVINOModelRunner(BaseModelRunner):
if device in ["GPU", "AUTO", "NPU"]:
self.ov_core.set_property(device, {"PERFORMANCE_HINT": "LATENCY"})
+ if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
+ try:
+ self.ov_core.set_property(device, {"NPU_TURBO": "YES"})
+ except Exception as e:
+ logger.debug(f"NPU_TURBO not supported by driver: {e}")
+
# Compile model
self.compiled_model = self.ov_core.compile_model(
model=model_path, device_name=device
From 26ecdb7e53cd6bfb5c82419488fe1953e39ab885 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 14:24:31 -0500
Subject: [PATCH 10/20] hide order fields
---
web/src/components/config-form/section-configs/birdseye.ts | 2 +-
web/src/components/config-form/section-configs/ui.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/src/components/config-form/section-configs/birdseye.ts b/web/src/components/config-form/section-configs/birdseye.ts
index c22e006554..f0538030dc 100644
--- a/web/src/components/config-form/section-configs/birdseye.ts
+++ b/web/src/components/config-form/section-configs/birdseye.ts
@@ -19,7 +19,7 @@ const birdseye: SectionConfigOverrides = {
],
restartRequired: [],
fieldOrder: ["enabled", "mode", "order"],
- hiddenFields: [],
+ hiddenFields: ["order"],
advancedFields: [],
overrideFields: ["enabled", "mode"],
uiSchema: {
diff --git a/web/src/components/config-form/section-configs/ui.ts b/web/src/components/config-form/section-configs/ui.ts
index bb1a50c248..d6a477f5b2 100644
--- a/web/src/components/config-form/section-configs/ui.ts
+++ b/web/src/components/config-form/section-configs/ui.ts
@@ -5,7 +5,7 @@ const ui: SectionConfigOverrides = {
sectionDocs: "/configuration/reference",
restartRequired: [],
fieldOrder: ["dashboard", "order"],
- hiddenFields: [],
+ hiddenFields: ["order"],
advancedFields: [],
overrideFields: [],
},
From a247a344ec1b6ef18967d68b1bafb02404666dee Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 14:38:31 -0500
Subject: [PATCH 11/20] drop auto-derived field paths from camera value when
unset globally
---
web/src/hooks/use-config-override.ts | 102 +++++++++++++++++++++++++--
1 file changed, 96 insertions(+), 6 deletions(-)
diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts
index 689e99f4b1..90ce717293 100644
--- a/web/src/hooks/use-config-override.ts
+++ b/web/src/hooks/use-config-override.ts
@@ -59,6 +59,64 @@ function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue {
return cloned;
}
+/**
+ * Field paths that the backend resolves per-camera at runtime (from `fps`,
+ * stream introspection, or other camera-local state) but defaults to `None`
+ * in the global Pydantic model. Because the `/config` endpoint serializes
+ * with `exclude_none=True`, these paths are absent from the global section
+ * yet always populated on cameras, which would otherwise make every camera
+ * appear to override fields the user never set globally.
+ */
+const AUTO_DERIVED_FIELDS: Record = {
+ detect: [
+ "width",
+ "height",
+ "min_initialized",
+ "max_disappeared",
+ "stationary.interval",
+ "stationary.threshold",
+ ],
+};
+
+/**
+ * Drop auto-derived field paths from the camera value when the global value
+ * has no explicit setting for that path. If the user later sets one of these
+ * fields globally, the path will be present in `globalValue` and normal
+ * comparison resumes.
+ */
+function stripAutoDerivedMissingFromGlobal(
+ sectionPath: string,
+ globalValue: JsonValue,
+ cameraValue: JsonValue,
+): JsonValue {
+ const fields = AUTO_DERIVED_FIELDS[sectionPath];
+ if (!fields || !isJsonObject(cameraValue)) return cameraValue;
+ const cloned = cloneDeep(cameraValue) as JsonObject;
+ for (const path of fields) {
+ if (get(globalValue, path) === undefined) {
+ unsetWithWildcard(cloned as Record, path);
+ }
+ }
+ return cloned;
+}
+
+/**
+ * Whether the given field is auto-derived for `sectionPath` and the global
+ * value at that path is missing — in which case a per-camera value should
+ * not be treated as an override.
+ */
+function isAutoDerivedMissingFromGlobal(
+ sectionPath: string,
+ fieldPath: string,
+ globalValue: unknown,
+): boolean {
+ const fields = AUTO_DERIVED_FIELDS[sectionPath];
+ if (!fields) return false;
+ if (!fields.includes(fieldPath)) return false;
+ const value = get(globalValue as JsonObject, fieldPath);
+ return value === undefined || value === null;
+}
+
/**
* Collapse null and empty-object values for override comparisons so
* semantically equivalent shapes match. The schema may default `mask: None`
@@ -234,10 +292,15 @@ export function useConfigOverride({
collapseEmpty(normalizedGlobalValue),
hiddenFields,
);
- const collapsedCamera = stripHiddenPaths(
+ const collapsedCameraRaw = stripHiddenPaths(
collapseEmpty(normalizedCameraValue),
hiddenFields,
);
+ const collapsedCamera = stripAutoDerivedMissingFromGlobal(
+ sectionPath,
+ collapsedGlobal,
+ collapsedCameraRaw,
+ );
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
@@ -258,6 +321,20 @@ export function useConfigOverride({
const globalFieldValue = get(normalizedGlobalValue, fieldPath);
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
+ if (
+ isAutoDerivedMissingFromGlobal(
+ sectionPath,
+ fieldPath,
+ normalizedGlobalValue,
+ )
+ ) {
+ return {
+ isOverridden: false,
+ globalValue: globalFieldValue,
+ cameraValue: cameraFieldValue,
+ };
+ }
+
return {
isOverridden: !isEqual(
collapseEmpty(globalFieldValue as JsonValue),
@@ -367,10 +444,15 @@ export function useAllCameraOverrides(
collapseEmpty(globalValue),
hiddenFields,
);
- const collapsedCamera = stripHiddenPaths(
+ const collapsedCameraRaw = stripHiddenPaths(
collapseEmpty(cameraValue),
hiddenFields,
);
+ const collapsedCamera = stripAutoDerivedMissingFromGlobal(
+ key,
+ collapsedGlobal,
+ collapsedCameraRaw,
+ );
const comparisonGlobal = compareFields
? pickFields(collapsedGlobal, compareFields)
: collapsedGlobal;
@@ -615,7 +697,11 @@ export function useCamerasOverridingSection(
const deltasByPath = new Map();
// 1. Camera-level overrides (uses base_config when a profile is active)
- const cameraValue = collapseEmpty(cameraSectionValues[idx]);
+ const cameraValue = stripAutoDerivedMissingFromGlobal(
+ sectionPath,
+ globalValue,
+ collapseEmpty(cameraSectionValues[idx]),
+ );
for (const delta of collectFieldDeltas(
globalValue,
cameraValue,
@@ -696,9 +782,13 @@ export function useCameraSectionDeltas(
const globalValue = collapseEmpty(
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
);
- const cameraValue = collapseEmpty(
- normalizeConfigValue(
- getBaseCameraSectionValue(config, cameraName, sectionPath),
+ const cameraValue = stripAutoDerivedMissingFromGlobal(
+ sectionPath,
+ globalValue,
+ collapseEmpty(
+ normalizeConfigValue(
+ getBaseCameraSectionValue(config, cameraName, sectionPath),
+ ),
),
);
From cb27e6ff1c360d99cf0fff8235bcaad267f981f3 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 14:39:39 -0500
Subject: [PATCH 12/20] use correct field type for export hwaccel args
---
web/src/components/config-form/section-configs/record.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/web/src/components/config-form/section-configs/record.ts b/web/src/components/config-form/section-configs/record.ts
index 0e0da30692..d1c2a94e64 100644
--- a/web/src/components/config-form/section-configs/record.ts
+++ b/web/src/components/config-form/section-configs/record.ts
@@ -46,7 +46,11 @@ const record: SectionConfigOverrides = {
uiSchema: {
export: {
hwaccel_args: {
- "ui:options": { suppressMultiSchema: true, size: "lg" },
+ "ui:widget": "FfmpegArgsWidget",
+ "ui:options": {
+ suppressMultiSchema: true,
+ ffmpegPresetField: "hwaccel_args",
+ },
},
},
"alerts.retain.mode": {
From d1b46706c9cc06af2216cc5ad34ef372ed9a1189 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 14:48:28 -0500
Subject: [PATCH 13/20] add debug replay to detail actions menu
---
web/public/locales/en/views/explore.json | 2 +-
.../overlay/detail/DetailActionsMenu.tsx | 78 ++++++++++++++++++-
2 files changed, 77 insertions(+), 3 deletions(-)
diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json
index 43db9bda48..d1087b3c96 100644
--- a/web/public/locales/en/views/explore.json
+++ b/web/public/locales/en/views/explore.json
@@ -222,7 +222,7 @@
"label": "Hide object path"
},
"debugReplay": {
- "label": "Debug replay",
+ "label": "Debug Replay",
"aria": "View this tracked object in the debug replay view"
},
"more": {
diff --git a/web/src/components/overlay/detail/DetailActionsMenu.tsx b/web/src/components/overlay/detail/DetailActionsMenu.tsx
index dc4ea5b2cf..789f396772 100644
--- a/web/src/components/overlay/detail/DetailActionsMenu.tsx
+++ b/web/src/components/overlay/detail/DetailActionsMenu.tsx
@@ -1,4 +1,6 @@
-import { useMemo, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
+import axios from "axios";
+import { toast } from "sonner";
import { Event } from "@/types/event";
import { baseUrl } from "@/api/baseUrl";
import { ReviewSegment, REVIEW_PADDING } from "@/types/review";
@@ -12,6 +14,7 @@ import {
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@/components/ui/dropdown-menu";
+import { Button } from "@/components/ui/button";
import { HiDotsHorizontal } from "react-icons/hi";
import { SearchResult } from "@/types/search";
import { FrigateConfig } from "@/types/frigateConfig";
@@ -33,9 +36,14 @@ export default function DetailActionsMenu({
setSearch,
setSimilarity,
}: Props) {
- const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
+ const { t } = useTranslation([
+ "views/explore",
+ "views/faceLibrary",
+ "views/replay",
+ ]);
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
+ const [isStarting, setIsStarting] = useState(false);
const isAdmin = useIsAdmin();
const clipTimeRange = useMemo(() => {
@@ -49,6 +57,54 @@ export default function DetailActionsMenu({
search.data?.type === "audio" ? null : [`review/event/${search.id}`],
);
+ const handleDebugReplay = useCallback(() => {
+ setIsStarting(true);
+
+ axios
+ .post("debug_replay/start", {
+ camera: search.camera,
+ start_time: search.start_time,
+ end_time: search.end_time,
+ })
+ .then((response) => {
+ if (response.status === 202 || response.status === 200) {
+ navigate("/replay");
+ }
+ })
+ .catch((error) => {
+ const errorMessage =
+ error.response?.data?.message ||
+ error.response?.data?.detail ||
+ "Unknown error";
+
+ if (error.response?.status === 409) {
+ toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
+ position: "top-center",
+ closeButton: true,
+ dismissible: false,
+ action: (
+
+
+
+ ),
+ });
+ } else {
+ toast.error(t("dialog.toast.error", { error: errorMessage }), {
+ position: "top-center",
+ });
+ }
+ })
+ .finally(() => {
+ setIsStarting(false);
+ });
+ }, [navigate, search.camera, search.start_time, search.end_time, t]);
+
// don't render menu at all if no options are available
const hasSemanticSearchOption =
config?.semantic_search.enabled &&
@@ -172,6 +228,24 @@ export default function DetailActionsMenu({
)}
+
+ {search.has_clip && (
+ {
+ setIsOpen(false);
+ handleDebugReplay();
+ }}
+ >
+
+ {isStarting
+ ? t("dialog.starting", { ns: "views/replay" })
+ : t("itemMenu.debugReplay.label")}
+
+
+ )}
From 2309628736aac7b7901047f9be0c17d936e40fec Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 14:53:45 -0500
Subject: [PATCH 14/20] clarify debug replay in docs
---
docs/docs/troubleshooting/dummy-camera.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/docs/troubleshooting/dummy-camera.md b/docs/docs/troubleshooting/dummy-camera.md
index e24c821290..aed0af5e68 100644
--- a/docs/docs/troubleshooting/dummy-camera.md
+++ b/docs/docs/troubleshooting/dummy-camera.md
@@ -37,6 +37,8 @@ The per-clip variation is typically quite low and is mostly an artifact of keyfr
Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time.
+Debug Replay isn't intended to be a one-stop pane for all Frigate diagnostics or a comprehensive debugging environment for every Frigate feature. It merely makes it easier to spin up a "dummy camera" and perform some common adjustments in real-time. You'll still need to use the normal tools (logs, an MQTT client, etc) to debug your feature.
+
### When to use
- Reproducing a detection or tracking issue from a specific time range
From 293012fce28f091514b214bc3d8115a8df5aacb9 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 15:02:28 -0500
Subject: [PATCH 15/20] guard get_current_frame_time against missing camera
state
---
frigate/track/object_processing.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py
index 3f8b5e626d..5832d8cdb8 100644
--- a/frigate/track/object_processing.py
+++ b/frigate/track/object_processing.py
@@ -357,6 +357,9 @@ class TrackedObjectProcessor(threading.Thread):
def get_current_frame_time(self, camera: str) -> float:
"""Returns the latest frame time for a given camera."""
+ if camera not in self.camera_states:
+ return 0.0
+
return self.camera_states[camera].current_frame_time
def set_sub_label(
From 77eaf9891e1fe1de2afd7372e242ce8108f38db1 Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Mon, 18 May 2026 15:21:31 -0600
Subject: [PATCH 16/20] Implement debug reply from export
---
frigate/api/debug_replay.py | 91 ++++++++++++++++++++++++++
web/src/components/card/ExportCard.tsx | 72 +++++++++++++++++++-
2 files changed, 162 insertions(+), 1 deletion(-)
diff --git a/frigate/api/debug_replay.py b/frigate/api/debug_replay.py
index 171bf1b98a..5f0b5f3e87 100644
--- a/frigate/api/debug_replay.py
+++ b/frigate/api/debug_replay.py
@@ -6,11 +6,14 @@ from datetime import datetime
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
+from peewee import DoesNotExist
from pydantic import BaseModel, Field
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
from frigate.jobs.debug_replay import start_debug_replay_job
+from frigate.models import Export
+from frigate.util.services import get_video_properties
logger = logging.getLogger(__name__)
@@ -25,6 +28,12 @@ class DebugReplayStartBody(BaseModel):
end_time: float = Field(title="End timestamp")
+class DebugReplayStartFromExportBody(BaseModel):
+ """Request body for starting a debug replay session from an export."""
+
+ export_id: str = Field(title="Export id")
+
+
class DebugReplayStartResponse(BaseModel):
"""Response for starting a debug replay session."""
@@ -112,6 +121,88 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody):
)
+@router.post(
+ "/debug_replay/start_from_export",
+ response_model=DebugReplayStartResponse,
+ status_code=202,
+ responses={
+ 400: {"description": "Invalid export, time range, or no recordings"},
+ 404: {"description": "Export not found"},
+ 409: {"description": "A replay session is already active"},
+ },
+ dependencies=[Depends(require_role(["admin"]))],
+ summary="Start debug replay from an export",
+ description="Start a debug replay session covering an existing export's "
+ "time range. The end time is derived from the export's video duration.",
+)
+async def start_debug_replay_from_export(
+ request: Request, body: DebugReplayStartFromExportBody
+):
+ """Start a debug replay session from an existing export."""
+ try:
+ export: Export = Export.get(Export.id == body.export_id)
+ except DoesNotExist:
+ return JSONResponse(
+ content={"success": False, "message": "Export not found"},
+ status_code=404,
+ )
+
+ start_ts = datetime.timestamp(export.date)
+ properties = await get_video_properties(
+ request.app.frigate_config.ffmpeg, export.video_path, get_duration=True
+ )
+ duration = properties.get("duration", -1)
+
+ if duration is None or duration <= 0:
+ return JSONResponse(
+ content={
+ "success": False,
+ "message": "Could not determine export duration",
+ },
+ status_code=400,
+ )
+
+ end_ts = start_ts + duration
+ replay_manager = request.app.replay_manager
+
+ try:
+ job_id = await asyncio.to_thread(
+ start_debug_replay_job,
+ source_camera=export.camera,
+ start_ts=start_ts,
+ end_ts=end_ts,
+ frigate_config=request.app.frigate_config,
+ config_publisher=request.app.config_publisher,
+ replay_manager=replay_manager,
+ )
+ except RuntimeError:
+ return JSONResponse(
+ content={
+ "success": False,
+ "message": "A replay session is already active",
+ },
+ status_code=409,
+ )
+ except ValueError:
+ logger.exception("Rejected debug replay start request")
+ return JSONResponse(
+ content={
+ "success": False,
+ "message": "Invalid debug replay parameters",
+ },
+ status_code=400,
+ )
+
+ return JSONResponse(
+ content={
+ "success": True,
+ "replay_camera": replay_manager.replay_camera_name,
+ "job_id": job_id,
+ },
+ status_code=202,
+ )
+
+
@router.get(
"/debug_replay/status",
response_model=DebugReplayStatusResponse,
diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx
index 893f251f8f..58600ed3fd 100644
--- a/web/src/components/card/ExportCard.tsx
+++ b/web/src/components/card/ExportCard.tsx
@@ -32,6 +32,9 @@ import { FaFolder, FaVideo } from "react-icons/fa";
import { HiSquare2Stack } from "react-icons/hi2";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import useContextMenu from "@/hooks/use-contextmenu";
+import axios from "axios";
+import { toast } from "sonner";
+import { useNavigate } from "react-router-dom";
type CaseCardProps = {
className: string;
@@ -123,11 +126,63 @@ export function ExportCard({
onAssignToCase,
onRemoveFromCase,
}: ExportCardProps) {
- const { t } = useTranslation(["views/exports"]);
+ const { t } = useTranslation(["views/exports", "views/replay"]);
+ const navigate = useNavigate();
const isAdmin = useIsAdmin();
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
);
+ const [isStartingReplay, setIsStartingReplay] = useState(false);
+
+ const handleDebugReplay = useCallback(() => {
+ setIsStartingReplay(true);
+
+ axios
+ .post("debug_replay/start_from_export", {
+ export_id: exportedRecording.id,
+ })
+ .then((response) => {
+ if (response.status === 202 || response.status === 200) {
+ navigate("/replay");
+ }
+ })
+ .catch((error) => {
+ const errorMessage =
+ error.response?.data?.message ||
+ error.response?.data?.detail ||
+ "Unknown error";
+
+ if (error.response?.status === 409) {
+ toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
+ position: "top-center",
+ closeButton: true,
+ dismissible: false,
+ action: (
+
+
+
+ ),
+ });
+ } else {
+ toast.error(
+ t("dialog.toast.error", {
+ ns: "views/replay",
+ error: errorMessage,
+ }),
+ { position: "top-center" },
+ );
+ }
+ })
+ .finally(() => {
+ setIsStartingReplay(false);
+ });
+ }, [exportedRecording.id, navigate, t]);
// Resync the skeleton state whenever the backing export changes. The
// list keys by id now, so in practice the component remounts instead
@@ -301,6 +356,21 @@ export function ExportCard({
{t("tooltip.downloadVideo")}
+ {isAdmin && (
+ {
+ e.stopPropagation();
+ handleDebugReplay();
+ }}
+ >
+ {isStartingReplay
+ ? t("dialog.starting", { ns: "views/replay" })
+ : t("title", { ns: "views/replay" })}
+
+ )}
{isAdmin && onAssignToCase && (
Date: Mon, 18 May 2026 15:44:41 -0600
Subject: [PATCH 17/20] Refactor debug replay to use sources for dynamic
playback
---
frigate/api/debug_replay.py | 22 ++-
frigate/jobs/debug_replay.py | 175 ++++++++++++++----
.../test/http_api/test_debug_replay_api.py | 7 +-
frigate/test/test_debug_replay_job.py | 55 +++---
4 files changed, 182 insertions(+), 77 deletions(-)
diff --git a/frigate/api/debug_replay.py b/frigate/api/debug_replay.py
index 5f0b5f3e87..2ba5d2b85f 100644
--- a/frigate/api/debug_replay.py
+++ b/frigate/api/debug_replay.py
@@ -11,7 +11,11 @@ from pydantic import BaseModel, Field
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
-from frigate.jobs.debug_replay import start_debug_replay_job
+from frigate.jobs.debug_replay import (
+ ExportDebugReplaySource,
+ RecordingDebugReplaySource,
+ start_debug_replay_job,
+)
from frigate.models import Export
from frigate.util.services import get_video_properties
@@ -82,13 +86,16 @@ class DebugReplayStopResponse(BaseModel):
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
"""Start a debug replay session asynchronously."""
replay_manager = request.app.replay_manager
+ source = RecordingDebugReplaySource(
+ source_camera=body.camera,
+ start_ts=body.start_time,
+ end_ts=body.end_time,
+ )
try:
job_id = await asyncio.to_thread(
start_debug_replay_job,
- source_camera=body.camera,
- start_ts=body.start_time,
- end_ts=body.end_time,
+ source=source,
frigate_config=request.app.frigate_config,
config_publisher=request.app.config_publisher,
replay_manager=replay_manager,
@@ -147,7 +154,6 @@ async def start_debug_replay_from_export(
status_code=404,
)
- start_ts = datetime.timestamp(export.date)
properties = await get_video_properties(
request.app.frigate_config.ffmpeg, export.video_path, get_duration=True
)
@@ -162,15 +168,13 @@ async def start_debug_replay_from_export(
status_code=400,
)
- end_ts = start_ts + duration
replay_manager = request.app.replay_manager
+ source = ExportDebugReplaySource(export=export, duration=float(duration))
try:
job_id = await asyncio.to_thread(
start_debug_replay_job,
- source_camera=export.camera,
- start_ts=start_ts,
- end_ts=end_ts,
+ source=source,
frigate_config=request.app.frigate_config,
config_publisher=request.app.config_publisher,
replay_manager=replay_manager,
diff --git a/frigate/jobs/debug_replay.py b/frigate/jobs/debug_replay.py
index 0616c46290..08f17d1f75 100644
--- a/frigate/jobs/debug_replay.py
+++ b/frigate/jobs/debug_replay.py
@@ -12,7 +12,9 @@ import os
import subprocess as sp
import threading
import time
+from abc import ABC, abstractmethod
from dataclasses import dataclass
+from datetime import datetime
from typing import TYPE_CHECKING, Any, Optional, cast
from peewee import ModelSelect
@@ -23,7 +25,7 @@ from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR
from frigate.jobs.export import JobStatePublisher
from frigate.jobs.job import Job
from frigate.jobs.manager import job_is_running, set_current_job
-from frigate.models import Recordings
+from frigate.models import Export, Recordings
from frigate.types import JobStatusTypesEnum
from frigate.util.ffmpeg import run_ffmpeg_with_progress
@@ -114,6 +116,121 @@ def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> Mode
return cast(ModelSelect, query)
+class DebugReplaySource(ABC):
+ """Abstract source for a debug replay session.
+
+ Provides the camera identity and time range the replay represents,
+ validates that usable content exists, and supplies the ffmpeg input
+ args used to build the replay clip.
+ """
+
+ @property
+ @abstractmethod
+ def source_camera(self) -> str:
+ """Camera name the replay is derived from."""
+
+ @property
+ @abstractmethod
+ def start_ts(self) -> float:
+ """Unix timestamp marking the start of the replay range."""
+
+ @property
+ @abstractmethod
+ def end_ts(self) -> float:
+ """Unix timestamp marking the end of the replay range."""
+
+ @abstractmethod
+ def validate(self) -> None:
+ """Raise ValueError if the source has no usable content."""
+
+ @abstractmethod
+ def ffmpeg_input_args(self, working_dir: str) -> list[str]:
+ """Return ffmpeg input args (including -i). May write temp files in working_dir."""
+
+ def cleanup(self, working_dir: str) -> None:
+ """Remove any temp files the source created in working_dir. Default no-op."""
+
+
+class RecordingDebugReplaySource(DebugReplaySource):
+ """Replay source backed by the Recordings table.
+
+ Builds a concat playlist of recording files covering the time range
+ and feeds it to ffmpeg's concat demuxer.
+ """
+
+ def __init__(self, source_camera: str, start_ts: float, end_ts: float) -> None:
+ self._camera = source_camera
+ self._start_ts = start_ts
+ self._end_ts = end_ts
+ self._concat_file: Optional[str] = None
+
+ @property
+ def source_camera(self) -> str:
+ return self._camera
+
+ @property
+ def start_ts(self) -> float:
+ return self._start_ts
+
+ @property
+ def end_ts(self) -> float:
+ return self._end_ts
+
+ def validate(self) -> None:
+ if self._end_ts <= self._start_ts:
+ raise ValueError("End time must be after start time")
+
+ if not query_recordings(self._camera, self._start_ts, self._end_ts).count():
+ raise ValueError(
+ f"No recordings found for camera '{self._camera}' in the specified time range"
+ )
+
+ def ffmpeg_input_args(self, working_dir: str) -> list[str]:
+ replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}"
+ concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt")
+ recordings = query_recordings(self._camera, self._start_ts, self._end_ts)
+ with open(concat_file, "w") as f:
+ for recording in recordings:
+ f.write(f"file '{recording.path}'\n")
+ self._concat_file = concat_file
+ return ["-f", "concat", "-safe", "0", "-i", concat_file]
+
+ def cleanup(self, working_dir: str) -> None:
+ if self._concat_file:
+ _remove_silent(self._concat_file)
+
+
+class ExportDebugReplaySource(DebugReplaySource):
+ """Replay source backed by an existing Export.
+
+ Uses the export's video file directly as the ffmpeg input — does not
+ require recordings to still exist for the time range.
+ """
+
+ def __init__(self, export: Export, duration: float) -> None:
+ self._export = export
+ self._duration = duration
+
+ @property
+ def source_camera(self) -> str:
+ return self._export.camera
+
+ @property
+ def start_ts(self) -> float:
+ return datetime.timestamp(self._export.date)
+
+ @property
+ def end_ts(self) -> float:
+ return self.start_ts + self._duration
+
+ def validate(self) -> None:
+ if not os.path.exists(self._export.video_path):
+ raise ValueError(f"Export video file not found: {self._export.video_path}")
+
+ def ffmpeg_input_args(self, working_dir: str) -> list[str]:
+ return ["-i", self._export.video_path]
+
+
class DebugReplayJobRunner(threading.Thread):
"""Worker thread that drives the startup job to completion.
@@ -126,6 +243,7 @@ class DebugReplayJobRunner(threading.Thread):
def __init__(
self,
job: DebugReplayJob,
+ source: DebugReplaySource,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: "DebugReplayManager",
@@ -133,6 +251,7 @@ class DebugReplayJobRunner(threading.Thread):
) -> None:
super().__init__(daemon=True, name=f"debug_replay_{job.id}")
self.job = job
+ self.source = source
self.frigate_config = frigate_config
self.config_publisher = config_publisher
self.replay_manager = replay_manager
@@ -183,7 +302,6 @@ class DebugReplayJobRunner(threading.Thread):
def run(self) -> None:
replay_name = self.job.replay_camera_name
os.makedirs(REPLAY_DIR, exist_ok=True)
- concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
self.job.status = JobStatusTypesEnum.running
@@ -192,23 +310,13 @@ class DebugReplayJobRunner(threading.Thread):
self._broadcast(force=True)
try:
- recordings = query_recordings(
- self.job.source_camera, self.job.start_ts, self.job.end_ts
- )
- with open(concat_file, "w") as f:
- for recording in recordings:
- f.write(f"file '{recording.path}'\n")
+ input_args = self.source.ffmpeg_input_args(REPLAY_DIR)
ffmpeg_cmd = [
self.frigate_config.ffmpeg.ffmpeg_path,
"-hide_banner",
"-y",
- "-f",
- "concat",
- "-safe",
- "0",
- "-i",
- concat_file,
+ *input_args,
"-c",
"copy",
"-movflags",
@@ -285,7 +393,7 @@ class DebugReplayJobRunner(threading.Thread):
self.replay_manager.clear_session()
_remove_silent(clip_path)
finally:
- _remove_silent(concat_file)
+ self.source.cleanup(REPLAY_DIR)
_set_active_runner(None)
def _finalize_cancelled(self, clip_path: str) -> None:
@@ -309,52 +417,43 @@ def _remove_silent(path: str) -> None:
def start_debug_replay_job(
*,
- source_camera: str,
- start_ts: float,
- end_ts: float,
+ source: DebugReplaySource,
frigate_config: FrigateConfig,
config_publisher: CameraConfigUpdatePublisher,
replay_manager: "DebugReplayManager",
) -> str:
"""Validate, create job, start runner. Returns the job id.
- Raises ValueError for bad params (camera missing, time range
- invalid, no recordings) and RuntimeError if a session is already
- active.
+ Raises ValueError for an invalid source (camera missing, source has
+ no usable content) and RuntimeError if a session is already active.
"""
if job_is_running(JOB_TYPE) or replay_manager.active:
raise RuntimeError("A replay session is already active")
- if source_camera not in frigate_config.cameras:
- raise ValueError(f"Camera '{source_camera}' not found")
+ if source.source_camera not in frigate_config.cameras:
+ raise ValueError(f"Camera '{source.source_camera}' not found")
- if end_ts <= start_ts:
- raise ValueError("End time must be after start time")
+ source.validate()
- recordings = query_recordings(source_camera, start_ts, end_ts)
- if not recordings.count():
- raise ValueError(
- f"No recordings found for camera '{source_camera}' in the specified time range"
- )
-
- replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
+ replay_name = f"{REPLAY_CAMERA_PREFIX}{source.source_camera}"
replay_manager.mark_starting(
- source_camera=source_camera,
+ source_camera=source.source_camera,
replay_camera_name=replay_name,
- start_ts=start_ts,
- end_ts=end_ts,
+ start_ts=source.start_ts,
+ end_ts=source.end_ts,
)
job = DebugReplayJob(
- source_camera=source_camera,
+ source_camera=source.source_camera,
replay_camera_name=replay_name,
- start_ts=start_ts,
- end_ts=end_ts,
+ start_ts=source.start_ts,
+ end_ts=source.end_ts,
)
set_current_job(job)
runner = DebugReplayJobRunner(
job=job,
+ source=source,
frigate_config=frigate_config,
config_publisher=config_publisher,
replay_manager=replay_manager,
diff --git a/frigate/test/http_api/test_debug_replay_api.py b/frigate/test/http_api/test_debug_replay_api.py
index 45c2c5478f..be4e7f496f 100644
--- a/frigate/test/http_api/test_debug_replay_api.py
+++ b/frigate/test/http_api/test_debug_replay_api.py
@@ -15,11 +15,12 @@ class TestDebugReplayAPI(BaseTestHttp):
# Stub the factory to skip validation/threading and just record the
# name on the manager the way the real factory's mark_starting would.
def fake_start(**kwargs):
+ source = kwargs["source"]
kwargs["replay_manager"].mark_starting(
- source_camera=kwargs["source_camera"],
+ source_camera=source.source_camera,
replay_camera_name="_replay_front",
- start_ts=kwargs["start_ts"],
- end_ts=kwargs["end_ts"],
+ start_ts=source.start_ts,
+ end_ts=source.end_ts,
)
return "job-1234"
diff --git a/frigate/test/test_debug_replay_job.py b/frigate/test/test_debug_replay_job.py
index 60997564f4..5e2da16720 100644
--- a/frigate/test/test_debug_replay_job.py
+++ b/frigate/test/test_debug_replay_job.py
@@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch
from frigate.debug_replay import DebugReplayManager
from frigate.jobs.debug_replay import (
DebugReplayJob,
+ RecordingDebugReplaySource,
cancel_debug_replay_job,
get_active_runner,
start_debug_replay_job,
@@ -99,9 +100,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
def test_rejects_unknown_camera(self) -> None:
with self.assertRaises(ValueError):
start_debug_replay_job(
- source_camera="missing",
- start_ts=100.0,
- end_ts=200.0,
+ source=RecordingDebugReplaySource(
+ source_camera="missing", start_ts=100.0, end_ts=200.0
+ ),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -110,9 +111,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
def test_rejects_invalid_time_range(self) -> None:
with self.assertRaises(ValueError):
start_debug_replay_job(
- source_camera="front",
- start_ts=200.0,
- end_ts=100.0,
+ source=RecordingDebugReplaySource(
+ source_camera="front", start_ts=200.0, end_ts=100.0
+ ),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -124,9 +125,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
with self.assertRaises(ValueError):
start_debug_replay_job(
- source_camera="front",
- start_ts=100.0,
- end_ts=200.0,
+ source=RecordingDebugReplaySource(
+ source_camera="front", start_ts=100.0, end_ts=200.0
+ ),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -154,9 +155,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
job_id = start_debug_replay_job(
- source_camera="front",
- start_ts=100.0,
- end_ts=200.0,
+ source=RecordingDebugReplaySource(
+ source_camera="front", start_ts=100.0, end_ts=200.0
+ ),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -191,9 +192,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
- source_camera="front",
- start_ts=100.0,
- end_ts=200.0,
+ source=RecordingDebugReplaySource(
+ source_camera="front", start_ts=100.0, end_ts=200.0
+ ),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -201,9 +202,9 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(RuntimeError):
start_debug_replay_job(
- source_camera="front",
- start_ts=100.0,
- end_ts=200.0,
+ source=RecordingDebugReplaySource(
+ source_camera="front", start_ts=100.0, end_ts=200.0
+ ),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -269,9 +270,9 @@ class TestRunnerHappyPath(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
- source_camera="front",
- start_ts=100.0,
- end_ts=200.0,
+ source=RecordingDebugReplaySource(
+ source_camera="front", start_ts=100.0, end_ts=200.0
+ ),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -340,9 +341,9 @@ class TestRunnerFailurePath(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
- source_camera="front",
- start_ts=100.0,
- end_ts=200.0,
+ source=RecordingDebugReplaySource(
+ source_camera="front", start_ts=100.0, end_ts=200.0
+ ),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
@@ -418,9 +419,9 @@ class TestRunnerCancellation(unittest.TestCase):
patch("builtins.open", unittest.mock.mock_open()),
):
start_debug_replay_job(
- source_camera="front",
- start_ts=100.0,
- end_ts=200.0,
+ source=RecordingDebugReplaySource(
+ source_camera="front", start_ts=100.0, end_ts=200.0
+ ),
frigate_config=self.frigate_config,
config_publisher=self.publisher,
replay_manager=self.manager,
From df5e6c593697fad908efb893038d5ede26cce288 Mon Sep 17 00:00:00 2001
From: Nicolas Mowen
Date: Mon, 18 May 2026 15:53:57 -0600
Subject: [PATCH 18/20] Mypy
---
frigate/jobs/debug_replay.py | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
diff --git a/frigate/jobs/debug_replay.py b/frigate/jobs/debug_replay.py
index 08f17d1f75..99007920ad 100644
--- a/frigate/jobs/debug_replay.py
+++ b/frigate/jobs/debug_replay.py
@@ -208,27 +208,29 @@ class ExportDebugReplaySource(DebugReplaySource):
"""
def __init__(self, export: Export, duration: float) -> None:
- self._export = export
+ self._camera = cast(str, export.camera)
+ self._start_ts = datetime.timestamp(cast(datetime, export.date))
+ self._video_path = cast(str, export.video_path)
self._duration = duration
@property
def source_camera(self) -> str:
- return self._export.camera
+ return self._camera
@property
def start_ts(self) -> float:
- return datetime.timestamp(self._export.date)
+ return self._start_ts
@property
def end_ts(self) -> float:
- return self.start_ts + self._duration
+ return self._start_ts + self._duration
def validate(self) -> None:
- if not os.path.exists(self._export.video_path):
- raise ValueError(f"Export video file not found: {self._export.video_path}")
+ if not os.path.exists(self._video_path):
+ raise ValueError(f"Export video file not found: {self._video_path}")
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
- return ["-i", self._export.video_path]
+ return ["-i", self._video_path]
class DebugReplayJobRunner(threading.Thread):
From 8202fda2f535e5e42ef6cb53827339152002de3f Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 17:06:59 -0500
Subject: [PATCH 19/20] fix debug export replay source timestamp handling
---
frigate/api/export.py | 2 +-
frigate/jobs/debug_replay.py | 5 +++--
frigate/test/test_media_auth.py | 2 +-
3 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/frigate/api/export.py b/frigate/api/export.py
index 2f4ca78da0..09ded84124 100644
--- a/frigate/api/export.py
+++ b/frigate/api/export.py
@@ -398,7 +398,7 @@ class _StreamingZipBuffer:
def _unique_archive_name(export: Export, used: set[str]) -> str:
base = sanitize_filename(export.name) if export.name else None
if not base:
- base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
+ base = f"{export.camera}_{int(export.date)}"
candidate = f"{base}.mp4"
counter = 1
diff --git a/frigate/jobs/debug_replay.py b/frigate/jobs/debug_replay.py
index 99007920ad..3dd7a02bf6 100644
--- a/frigate/jobs/debug_replay.py
+++ b/frigate/jobs/debug_replay.py
@@ -14,7 +14,6 @@ import threading
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
-from datetime import datetime
from typing import TYPE_CHECKING, Any, Optional, cast
from peewee import ModelSelect
@@ -209,7 +208,9 @@ class ExportDebugReplaySource(DebugReplaySource):
def __init__(self, export: Export, duration: float) -> None:
self._camera = cast(str, export.camera)
- self._start_ts = datetime.timestamp(cast(datetime, export.date))
+ # Export.date is declared DateTimeField but Frigate writes raw unix
+ # timestamps to the column.
+ self._start_ts = float(cast(Any, export.date))
self._video_path = cast(str, export.video_path)
self._duration = duration
diff --git a/frigate/test/test_media_auth.py b/frigate/test/test_media_auth.py
index 80dc6fb8b0..d025fea614 100644
--- a/frigate/test/test_media_auth.py
+++ b/frigate/test/test_media_auth.py
@@ -230,7 +230,7 @@ class TestExportResolution(unittest.TestCase):
id=export_id,
camera=camera,
name=f"export-{export_id}",
- date=datetime.datetime.now(),
+ date=int(datetime.datetime.now().timestamp()),
video_path=f"/media/frigate/exports/{filename}",
thumb_path=f"/media/frigate/exports/{filename}.jpg",
in_progress=False,
From 6e03dc6fffd282ea72fa27812eac0268d988b5b5 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Mon, 18 May 2026 17:10:08 -0500
Subject: [PATCH 20/20] skip replay cameras in stats immediately
---
web/src/hooks/use-stats.ts | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts
index 77dd9fc019..5a1922a744 100644
--- a/web/src/hooks/use-stats.ts
+++ b/web/src/hooks/use-stats.ts
@@ -102,6 +102,11 @@ export default function useStats(stats: FrigateStats | undefined) {
// check camera cpu usages
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
+ // Skip replay cameras
+ if (isReplayCamera(name)) {
+ return;
+ }
+
const ffmpegAvg = parseFloat(
memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
);
@@ -111,12 +116,7 @@ export default function useStats(stats: FrigateStats | undefined) {
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
- // Skip ffmpeg warnings for replay cameras
- if (
- !isNaN(ffmpegAvg) &&
- ffmpegAvg >= CameraFfmpegThreshold.error &&
- !isReplayCamera(name)
- ) {
+ if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
problems.push({
text: t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),