From 310b5dfe05f5bed5515b4313c6dcbf833b7d123d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 15 Mar 2026 07:26:23 -0500 Subject: [PATCH 01/16] UI tweaks and fixes (#22448) * fix double scrollbar in debug replay * always hide ffmpeg cpu warnings for replay cameras * add slovenian * fix motion previews on safari and ios match the logic used in ScrubbablePreview for manually stepping currentTime at the correct rate * prevent motion recalibration when opening motion tuner --- web/src/hooks/use-date-locale.ts | 1 + web/src/hooks/use-stats.ts | 6 +- web/src/lib/const.ts | 1 + web/src/pages/Replay.tsx | 17 +++- web/src/views/events/MotionPreviewsPane.tsx | 89 +++++++++++---------- web/src/views/settings/MotionTunerView.tsx | 16 +++- 6 files changed, 74 insertions(+), 56 deletions(-) diff --git a/web/src/hooks/use-date-locale.ts b/web/src/hooks/use-date-locale.ts index 7a8085666..901162fe4 100644 --- a/web/src/hooks/use-date-locale.ts +++ b/web/src/hooks/use-date-locale.ts @@ -38,6 +38,7 @@ const localeMap: Record Promise> = { th: () => import("date-fns/locale/th").then((module) => module.th), ca: () => import("date-fns/locale/ca").then((module) => module.ca), hr: () => import("date-fns/locale/hr").then((module) => module.hr), + sl: () => import("date-fns/locale/sl").then((module) => module.sl), }; export function useDateLocale(): Locale { diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 5bddb75ac..bfb3bafd8 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -106,13 +106,11 @@ export default function useStats(stats: FrigateStats | undefined) { const cameraName = config?.cameras?.[name]?.friendly_name ?? name; - // Skip ffmpeg warnings for replay cameras when debug replay is active + // Skip ffmpeg warnings for replay cameras if ( !isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error && - !( - debugReplayStatus?.active && debugReplayStatus?.replay_camera === name - ) + !isReplayCamera(name) ) { problems.push({ text: t("stats.ffmpegHighCpuUsage", { diff --git a/web/src/lib/const.ts b/web/src/lib/const.ts index 5000d7a0b..a11239613 100644 --- a/web/src/lib/const.ts +++ b/web/src/lib/const.ts @@ -26,6 +26,7 @@ export const supportedLanguageKeys = [ "pl", "hr", "sk", + "sl", "lt", "uk", "cs", diff --git a/web/src/pages/Replay.tsx b/web/src/pages/Replay.tsx index 187a9a76b..50927d1dd 100644 --- a/web/src/pages/Replay.tsx +++ b/web/src/pages/Replay.tsx @@ -381,7 +381,7 @@ export default function Replay() { {/* Side panel */} -
+
{t("title")} @@ -399,7 +399,10 @@ export default function Replay() {

{t("description")}

- + {t("debug.debugging", { ns: "views/settings" })} @@ -409,7 +412,10 @@ export default function Replay() { {t("websocket_messages")} - +
{DEBUG_OPTION_KEYS.map((key) => { @@ -554,7 +560,10 @@ export default function Replay() {
- + | null>(null); + + useEffect(() => { + return () => { + if (compatIntervalRef.current) { + clearInterval(compatIntervalRef.current); + } + }; + }, []); + const resetPlayback = useCallback(() => { if (!videoRef.current || !preview) { return; } + if (compatIntervalRef.current) { + clearInterval(compatIntervalRef.current); + compatIntervalRef.current = null; + } + videoRef.current.currentTime = clipStart; - videoRef.current.playbackRate = playbackRate; - }, [clipStart, playbackRate, preview]); - useEffect(() => { - if (!videoRef.current || !preview) { - return; - } - - if (!isVisible) { + if (isSafari || (isFirefox && isMobile)) { + // Safari / iOS can't play at speeds > 2x, so manually step through frames videoRef.current.pause(); - videoRef.current.currentTime = clipStart; - return; - } + compatIntervalRef.current = setInterval(() => { + if (!videoRef.current) { + return; + } - if (videoRef.current.readyState >= 2) { - resetPlayback(); - void videoRef.current.play().catch(() => undefined); + videoRef.current.currentTime += 1; + + if (videoRef.current.currentTime >= clipEnd) { + videoRef.current.currentTime = clipStart; + } + }, 1000 / playbackRate); + } else { + videoRef.current.playbackRate = playbackRate; } - }, [clipStart, isVisible, preview, resetPlayback]); + }, [clipStart, clipEnd, playbackRate, preview]); const drawDimOverlay = useCallback(() => { if (!dimOverlayCanvasRef.current) { @@ -463,15 +479,17 @@ function MotionPreviewClip({ {showLoadingIndicator && ( )} - {preview ? ( + {preview && isVisible ? ( <> {motionHeatmap && ( { if (config && selectedCamera) { return config.cameras[selectedCamera]; @@ -70,6 +72,7 @@ export default function MotionTunerView({ }, [config, selectedCamera]); useEffect(() => { + userInteractedRef.current = false; if (cameraConfig) { setMotionSettings({ threshold: cameraConfig.motion.threshold, @@ -87,24 +90,29 @@ export default function MotionTunerView({ }, [selectedCamera]); useEffect(() => { - if (!motionSettings.threshold) return; + if (!motionSettings.threshold || !userInteractedRef.current) return; sendMotionThreshold(motionSettings.threshold); }, [motionSettings.threshold, sendMotionThreshold]); useEffect(() => { - if (!motionSettings.contour_area) return; + if (!motionSettings.contour_area || !userInteractedRef.current) return; sendMotionContourArea(motionSettings.contour_area); }, [motionSettings.contour_area, sendMotionContourArea]); useEffect(() => { - if (motionSettings.improve_contrast === undefined) return; + if ( + motionSettings.improve_contrast === undefined || + !userInteractedRef.current + ) + return; sendImproveContrast(motionSettings.improve_contrast ? "ON" : "OFF"); }, [motionSettings.improve_contrast, sendImproveContrast]); const handleMotionConfigChange = (newConfig: Partial) => { + userInteractedRef.current = true; setMotionSettings((prevConfig) => ({ ...prevConfig, ...newConfig })); setUnsavedChanges(true); setChangedValue(true); From 5a214eb0d1634f87388174f96aa262edd282f23e Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 15 Mar 2026 06:26:36 -0600 Subject: [PATCH 02/16] Review fixes (#22442) * Don't set provider options for llama.cpp as they are set on llama.cpp side * fix openai format --- frigate/genai/__init__.py | 3 +++ frigate/genai/llama_cpp.py | 7 ------- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/frigate/genai/__init__.py b/frigate/genai/__init__.py index 272420dab..3e939d28d 100644 --- a/frigate/genai/__init__.py +++ b/frigate/genai/__init__.py @@ -153,6 +153,9 @@ Each line represents a detection state, not necessarily unique individuals. Pare if "other_concerns" in schema.get("required", []): schema["required"].remove("other_concerns") + # OpenAI strict mode requires additionalProperties: false on all objects + schema["additionalProperties"] = False + response_format = { "type": "json_schema", "json_schema": { diff --git a/frigate/genai/llama_cpp.py b/frigate/genai/llama_cpp.py index 87443ac4f..ac698b3b6 100644 --- a/frigate/genai/llama_cpp.py +++ b/frigate/genai/llama_cpp.py @@ -36,19 +36,12 @@ def _to_jpeg(img_bytes: bytes) -> bytes | None: class LlamaCppClient(GenAIClient): """Generative AI client for Frigate using llama.cpp server.""" - LOCAL_OPTIMIZED_OPTIONS = { - "temperature": 0.7, - "repeat_penalty": 1.05, - "top_p": 0.8, - } - provider: str # base_url provider_options: dict[str, Any] def _init_provider(self): """Initialize the client.""" self.provider_options = { - **self.LOCAL_OPTIMIZED_OPTIONS, **self.genai_config.provider_options, } return ( From bc29c4ba7108050f36c2b6410c5a377f325ee1d2 Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:42:03 +0800 Subject: [PATCH 03/16] fix: off-by-one error in GpuSelector.get_gpu_arg (#22464) gpu <= len(self._valid_gpus) should be gpu < len(self._valid_gpus). The list is zero-indexed, so requesting gpu index equal to the list length causes an IndexError. For example, with 2 valid GPUs (indices 0 and 1), requesting gpu=2 passes the check (2 <= 2) but self._valid_gpus[2] is out of bounds. --- frigate/ffmpeg_presets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index 43272a6d1..20e1620e1 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -63,7 +63,7 @@ class LibvaGpuSelector: if not self._valid_gpus: return "" - if gpu <= len(self._valid_gpus): + if gpu < len(self._valid_gpus): return self._valid_gpus[gpu] else: logger.warning(f"Invalid GPU index {gpu}, using first valid GPU") From dfc6ff9202104d710aba4803b742c6da19a49df0 Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:43:25 +0800 Subject: [PATCH 04/16] fix: variable shadowing silently drops object label updates (#22472) When an existing tracked object's label or stationary status changes (e.g. sub_label assignment from face recognition), the update handler declares a new const newObjects that shadows the outer let newObjects. The label and stationary mutations apply to the inner copy, but handleSetObjects on line 148 reads the outer variable which was never mutated. The update is silently discarded. Remove the inner declaration so mutations apply to the outer variable that gets passed to handleSetObjects. --- web/src/hooks/use-camera-activity.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index 71507c2af..76a982725 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -125,8 +125,6 @@ export function useCameraActivity( newObjects = [...(objects ?? []), newActiveObject]; } } else { - const newObjects = [...(objects ?? [])]; - let label = updatedEvent.after.label; if (updatedEvent.after.sub_label) { From 09df43a6753e73733c91845038c60e23ce53490d Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:43:56 +0800 Subject: [PATCH 05/16] fix: return ValueError should be raise ValueError (#22474) escape_special_characters() returns a ValueError object instead of raising it when the input path exceeds 1000 characters. The exception object gets used as a string downstream instead of triggering error handling. --- frigate/util/builtin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index aa2417a5c..42aa18c0a 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -116,7 +116,7 @@ def clean_camera_user_pass(line: str) -> str: def escape_special_characters(path: str) -> str: """Cleans reserved characters to encodings for ffmpeg.""" if len(path) > 1000: - return ValueError("Input too long to check") + raise ValueError("Input too long to check") try: found = re.search(REGEX_RTSP_CAMERA_USER_PASS, path).group(0)[3:-1] From bf2dcfd622242ce4213a10a771dddb68b72c8cce Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:44:26 +0800 Subject: [PATCH 06/16] fix: reset active_cameras to set() not list in error handler (#22467) In BirdsEyeFrameManager.update(), the exception handler on line 756 resets self.active_cameras to [] (a list), but it is initialized as set() and compared as a set throughout the rest of the code. Since set() \!= [] evaluates to True even though both are empty, the next call to update_frame() will incorrectly detect a layout change and trigger an unnecessary frame rebuild after every exception. --- frigate/output/birdseye.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index cdadafe71..05d72be6a 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -753,7 +753,7 @@ class BirdsEyeFrameManager: frame_changed, layout_changed = self.update_frame(frame) except Exception: frame_changed, layout_changed = False, False - self.active_cameras = [] + self.active_cameras = set() self.camera_layout = [] print(traceback.format_exc()) From 49ffd0b01a2f0fefcc2f28b13f2beab2eff91f86 Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:46:31 +0800 Subject: [PATCH 07/16] fix: handle custom logo images without alpha channel (#22468) cv2.imread with IMREAD_UNCHANGED loads the image as-is, but the code unconditionally indexes channel 3 (birdseye_logo[:, :, 3]) assuming RGBA format. This crashes with IndexError for: - Grayscale PNGs (2D array, no channel dimension) - RGB PNGs without alpha (3 channels, no index 3) - Fully transparent PNGs saved as grayscale+alpha (2 channels) Handle all image formats: - 2D (grayscale): use directly as luminance - 4+ channels (RGBA): extract alpha channel (existing behavior) - 3 channels (RGB/BGR): convert to grayscale Also fixes the shape[0]/shape[1] swap in the array slice that breaks non-square images (related to #6802, #7863). --- frigate/output/birdseye.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 05d72be6a..5d80de33c 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -303,12 +303,20 @@ class BirdsEyeFrameManager: birdseye_logo = cv2.imread(logo_files[0], cv2.IMREAD_UNCHANGED) if birdseye_logo is not None: - transparent_layer = birdseye_logo[:, :, 3] + if birdseye_logo.ndim == 2: + # Grayscale image (no channels) — use directly as luminance + transparent_layer = birdseye_logo + elif birdseye_logo.shape[2] >= 4: + # RGBA — use alpha channel as luminance + transparent_layer = birdseye_logo[:, :, 3] + else: + # RGB or other format without alpha — convert to grayscale + transparent_layer = cv2.cvtColor(birdseye_logo, cv2.COLOR_BGR2GRAY) y_offset = height // 2 - transparent_layer.shape[0] // 2 x_offset = width // 2 - transparent_layer.shape[1] // 2 self.blank_frame[ - y_offset : y_offset + transparent_layer.shape[1], - x_offset : x_offset + transparent_layer.shape[0], + y_offset : y_offset + transparent_layer.shape[0], + x_offset : x_offset + transparent_layer.shape[1], ] = transparent_layer else: logger.warning("Unable to read Frigate logo") From e80da2b2971c50c95a7fcb7a484ce2213fe64962 Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:47:07 +0800 Subject: [PATCH 08/16] fix: WebSocket connection leaked on WebRTC player cleanup (#22473) The connect() function creates a WebSocket but never stores the reference. The useEffect cleanup only closes the RTCPeerConnection via pcRef, leaving the WebSocket open. Each time the component re-renders with changed deps (camera switch, playback toggle, microphone toggle), a new WebSocket is created without closing the previous one. This leaks connections until the browser garbage-collects them or the server times out. Store the WebSocket in a ref and close it in the cleanup function. --- web/src/components/player/WebRTCPlayer.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/components/player/WebRTCPlayer.tsx b/web/src/components/player/WebRTCPlayer.tsx index 147af43ea..d78c7dd80 100644 --- a/web/src/components/player/WebRTCPlayer.tsx +++ b/web/src/components/player/WebRTCPlayer.tsx @@ -52,6 +52,7 @@ export default function WebRtcPlayer({ // camera states const pcRef = useRef(undefined); + const wsRef = useRef(null); const videoRef = useRef(null); const [bufferTimeout, setBufferTimeout] = useState(); const videoLoadTimeoutRef = useRef(undefined); @@ -129,7 +130,8 @@ export default function WebRtcPlayer({ } pcRef.current = await aPc; - const ws = new WebSocket(wsURL); + wsRef.current = new WebSocket(wsURL); + const ws = wsRef.current; ws.addEventListener("open", () => { pcRef.current?.addEventListener("icecandidate", (ev) => { @@ -183,6 +185,10 @@ export default function WebRtcPlayer({ connect(aPc); return () => { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } if (pcRef.current) { pcRef.current.close(); pcRef.current = undefined; From 6d7b1ce384ead057cae07928281c70fe389a0375 Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:47:24 +0800 Subject: [PATCH 09/16] fix: inverted condition causes division by zero in velocity direction check (#22470) The cosine similarity calculation is guarded by: if not np.any(np.linalg.norm(velocities, axis=1)) This enters the block when ALL velocity norms are zero, then divides by those zero norms. The condition should check that all norms are non-zero before computing cosine similarity: if np.all(np.linalg.norm(velocities, axis=1)) Also fixes debug log that shows average_velocity[0] for both x and y velocity components (second should be average_velocity[1]). --- frigate/ptz/autotrack.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index eb2d16940..1a45f619c 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -901,7 +901,7 @@ class PtzAutoTracker: # Check direction difference velocities = np.round(velocities) invalid_dirs = False - if not np.any(np.linalg.norm(velocities, axis=1)): + if np.all(np.linalg.norm(velocities, axis=1)): cosine_sim = np.dot(velocities[0], velocities[1]) / ( np.linalg.norm(velocities[0]) * np.linalg.norm(velocities[1]) ) @@ -1067,7 +1067,7 @@ class PtzAutoTracker: f"{camera}: Zoom test: below dimension threshold: {below_dimension_threshold} width: {bb_right - bb_left}, max width: {camera_width * (self.zoom_factor[camera] + 0.1)}, height: {bb_bottom - bb_top}, max height: {camera_height * (self.zoom_factor[camera] + 0.1)}" ) logger.debug( - f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[0])}, y threshold: {velocity_threshold_y}" + f"{camera}: Zoom test: below velocity threshold: {below_velocity_threshold} velocity x: {abs(average_velocity[0])}, x threshold: {velocity_threshold_x}, velocity y: {abs(average_velocity[1])}, y threshold: {velocity_threshold_y}" ) logger.debug(f"{camera}: Zoom test: at max zoom: {at_max_zoom}") logger.debug(f"{camera}: Zoom test: at min zoom: {at_min_zoom}") From 7485b48f0ee9111de8fed9c78ab7ac5cfd05337b Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:48:35 +0800 Subject: [PATCH 10/16] fix: iterator exhausted by debug log prevents event cleanup (#22469) In both expire_snapshots() and expire_clips(), the expired_events query uses .iterator() for lazy evaluation, but the very next line calls list(expired_events) inside an f-string for debug logging. This consumes the entire iterator, so the subsequent for loop that deletes media files from disk iterates over an exhausted iterator and processes zero events. Snapshots and clips for removed cameras are never deleted from disk, causing gradual disk space exhaustion. Materialize the iterator into a list before logging so both the debug message and the cleanup loop use the same data. --- frigate/events/cleanup.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index 1ac03b2ed..1b9e1a673 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -95,7 +95,8 @@ class EventCleanup(threading.Thread): .namedtuples() .iterator() ) - logger.debug(f"{len(list(expired_events))} events can be expired") + expired_events = list(expired_events) + logger.debug(f"{len(expired_events)} events can be expired") # delete the media from disk for expired in expired_events: @@ -220,7 +221,8 @@ class EventCleanup(threading.Thread): .namedtuples() .iterator() ) - logger.debug(f"{len(list(expired_events))} events can be expired") + expired_events = list(expired_events) + logger.debug(f"{len(expired_events)} events can be expired") # delete the media from disk for expired in expired_events: media_name = f"{expired.camera}-{expired.id}" From c08ec9652f5bd2977d9355a8d2b8a82a41b6ab02 Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:48:54 +0800 Subject: [PATCH 11/16] fix: swap shape indices in birdseye custom logo assignment (#22463) In BirdsEyeFrameManager.__init__(), the numpy slice that copies the custom logo (transparent_layer from custom.png alpha channel) onto blank_frame has shape[0] and shape[1] swapped: blank_frame[y:y+shape[1], x:x+shape[0]] = transparent_layer shape[0] is rows (height) and shape[1] is cols (width), so the row range needs shape[0] and the column range needs shape[1]: blank_frame[y:y+shape[0], x:x+shape[1]] = transparent_layer The bug is masked for square images where shape[0]==shape[1]. For non-square images (e.g. 1920x1080), it produces: ValueError: could not broadcast input array from shape (1080,1920) into shape (1620,1080) This silently kills the birdseye output process -- no frames are written to the FIFO pipe, go2rtc exec ffmpeg times out, and the birdseye restream shows a black screen with no errors in the UI. From bd289f314650bc157f6e5b5722057dd3425dd692 Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:57:04 +0800 Subject: [PATCH 12/16] fix: pass ffmpeg_path to birdseye encode preset format string (#22462) When hwaccel_args is a list (not a preset string), the fallback in parse_preset_hardware_acceleration_encode() calls arg_map["default"].format(input, output) with only 2 positional args. But PRESETS_HW_ACCEL_ENCODE_BIRDSEYE["default"] contains {0}, {1}, {2} expecting ffmpeg_path as the first arg. This causes IndexError: Replacement index 2 out of range for size 2 which crashes create_config.py on every go2rtc start, taking down all camera streams. Pass ffmpeg_path as the first argument to match the preset template. --- frigate/ffmpeg_presets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index 20e1620e1..75e2b901d 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -278,7 +278,7 @@ def parse_preset_hardware_acceleration_encode( arg_map = PRESETS_HW_ACCEL_ENCODE_TIMELAPSE if not isinstance(arg, str): - return arg_map["default"].format(input, output) + return arg_map["default"].format(ffmpeg_path, input, output) # Not all jetsons have HW encoders, so fall back to default SW encoder if not if arg.startswith("preset-jetson-") and not os.path.exists("/dev/nvhost-msenc"): From 722ef6a1fed8e0c1079597d60fcdd7017b6e8735 Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:57:14 +0800 Subject: [PATCH 13/16] fix: wrong index for FPS replacement in preset-http-jpeg-generic (#22465) parse_preset_input() uses input[len(_user_agent_args) + 1] to find the FPS placeholder, but preset-http-jpeg-generic does not include _user_agent_args at the start of its list (only preset-http-mjpeg-generic does). The FPS placeholder '{}' is at index 1, not index 3. This means the detect_fps value overwrites '-1' (the stream_loop argument) instead of the '{}' FPS placeholder, so the preset always uses the literal string '{}' as the framerate. --- frigate/ffmpeg_presets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/ffmpeg_presets.py b/frigate/ffmpeg_presets.py index 75e2b901d..ec40bf0c5 100644 --- a/frigate/ffmpeg_presets.py +++ b/frigate/ffmpeg_presets.py @@ -436,7 +436,7 @@ def parse_preset_input(arg: Any, detect_fps: int) -> list[str]: if arg == "preset-http-jpeg-generic": input = PRESETS_INPUT[arg].copy() - input[len(_user_agent_args) + 1] = str(detect_fps) + input[1] = str(detect_fps) return input return PRESETS_INPUT.get(arg, None) From aea91a91d5b961178b3d0a89d6117e0d33e06574 Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:23:44 +0800 Subject: [PATCH 14/16] fix: use parameterized query in get_face_ids to prevent SQL injection (#22500) The name parameter was interpolated directly into the SQL query via f-string, allowing SQL injection through crafted face name values. Use a parameterized query with ? placeholder instead. --- frigate/embeddings/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index 0a854fcfa..5e14d0d8c 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -205,14 +205,14 @@ class EmbeddingsContext: ) def get_face_ids(self, name: str) -> list[str]: - sql_query = f""" + sql_query = """ SELECT id FROM vec_descriptions - WHERE id LIKE '%{name}%' + WHERE id LIKE ? """ - return self.db.execute_sql(sql_query).fetchall() + return self.db.execute_sql(sql_query, (f"%{name}%",)).fetchall() def reprocess_face(self, face_file: str) -> dict[str, Any]: return self.requestor.send_data( From 7708523865d1b7f895988bfa7d0af4ba17415fee Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:33:40 +0800 Subject: [PATCH 15/16] fix: update correct metric in batch_embed_thumbnail (#22501) batch_embed_thumbnail processes image thumbnails but reports timing to text_inference_speed instead of image_inference_speed. --- frigate/embeddings/embeddings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index d31d1b058..91144c3fa 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -266,7 +266,7 @@ class Embeddings: ) duration = datetime.datetime.now().timestamp() - start - self.text_inference_speed.update(duration / len(valid_ids)) + self.image_inference_speed.update(duration / len(valid_ids)) return embeddings From dc27d4ad1637713feb6792a182f7ecd29a47b9d7 Mon Sep 17 00:00:00 2001 From: ryzendigo <48058157+ryzendigo@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:34:30 +0800 Subject: [PATCH 16/16] fix: upload_image parses response body before checking HTTP status (#22475) * fix: check HTTP response status before parsing JSON body upload_image() calls r.json() before checking r.ok. If the server returns an error response (401, 500, etc) with a non-JSON body, this raises a confusing JSONDecodeError instead of the intended 'Unable to get signed urls' error message. Move the r.ok check before the r.json() call. * style: remove extra blank line for ruff --- frigate/plus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/plus.py b/frigate/plus.py index 197b6e48d..2870d2ae5 100644 --- a/frigate/plus.py +++ b/frigate/plus.py @@ -105,9 +105,9 @@ class PlusApi: def upload_image(self, image: ndarray, camera: str) -> str: r = self._get("image/signed_urls") - presigned_urls = r.json() if not r.ok: raise Exception("Unable to get signed urls") + presigned_urls = r.json() # resize and submit original files = {"file": get_jpg_bytes(image, 1920, 85)}