From a6c5f4a82bbdee35da6e51c19c9ac7f2662e8163 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:42:23 -0600 Subject: [PATCH 1/5] use latest preview frame for latest image when camera is offline --- frigate/api/media.py | 49 +++++++++++---- frigate/output/preview.py | 129 +++++++++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 16 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index 9f2d62401..45f320647 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -42,6 +42,7 @@ from frigate.const import ( PREVIEW_FRAME_TYPE, ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment +from frigate.output.preview import get_most_recent_preview_frame from frigate.track.object_processing import TrackedObjectProcessor from frigate.util.file import get_event_thumbnail_bytes from frigate.util.image import get_image_from_recording @@ -159,20 +160,38 @@ async def latest_frame( or 10 ) + is_offline = False if frame is None or datetime.now().timestamp() > ( frame_processor.get_current_frame_time(camera_name) + retry_interval ): - if request.app.camera_error_image is None: - error_image = glob.glob( - os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg") - ) + last_frame_time = frame_processor.get_current_frame_time(camera_name) + config: FrigateConfig = request.app.frigate_config + preview_path = get_most_recent_preview_frame( + camera_name, before=last_frame_time, ffmpeg_config=config.ffmpeg + ) - if len(error_image) > 0: - request.app.camera_error_image = cv2.imread( - error_image[0], cv2.IMREAD_UNCHANGED + if preview_path: + logger.debug(f"Using most recent preview frame for {camera_name}") + frame = cv2.imread(preview_path, cv2.IMREAD_UNCHANGED) + + if frame is not None: + is_offline = True + + if frame is None or not is_offline: + logger.debug( + f"No live or preview frame available for {camera_name}. Using error image." + ) + if request.app.camera_error_image is None: + error_image = glob.glob( + os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg") ) - frame = request.app.camera_error_image + if len(error_image) > 0: + request.app.camera_error_image = cv2.imread( + error_image[0], cv2.IMREAD_UNCHANGED + ) + + frame = request.app.camera_error_image height = int(params.height or str(frame.shape[0])) width = int(height * frame.shape[1] / frame.shape[0]) @@ -194,14 +213,18 @@ async def latest_frame( frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA) _, img = cv2.imencode(f".{extension.value}", frame, quality_params) + + headers = { + "Cache-Control": "no-store" if not params.store else "private, max-age=60", + } + + if is_offline: + headers["X-Frigate-Offline"] = "true" + return Response( content=img.tobytes(), media_type=extension.get_mime_type(), - headers={ - "Cache-Control": "no-store" - if not params.store - else "private, max-age=60", - }, + headers=headers, ) elif ( camera_name == "birdseye" diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 6dfd90904..1493c1b73 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -8,13 +8,14 @@ import subprocess as sp import threading import time from pathlib import Path -from typing import Any +from typing import Any, Optional import cv2 import numpy as np +from peewee import DoesNotExist from frigate.comms.inter_process import InterProcessRequestor -from frigate.config import CameraConfig, RecordQualityEnum +from frigate.config import CameraConfig, FfmpegConfig, RecordQualityEnum from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW, PREVIEW_FRAME_TYPE from frigate.ffmpeg_presets import ( FPS_VFR_PARAM, @@ -23,7 +24,12 @@ from frigate.ffmpeg_presets import ( ) from frigate.models import Previews from frigate.track.object_processing import TrackedObject -from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop +from frigate.util.image import ( + copy_yuv_to_position, + get_blank_yuv_frame, + get_yuv_crop, + run_ffmpeg_snapshot, +) logger = logging.getLogger(__name__) @@ -57,6 +63,123 @@ def get_cache_image_name(camera: str, frame_time: float) -> str: ) +def get_most_recent_preview_frame( + camera: str, before: float = None, ffmpeg_config: Optional[FfmpegConfig] = None +) -> str | None: + """Get the most recent preview frame for a camera. + + First tries to find a cached preview webp frame. If none exists (e.g., they've been + cleaned up after preview video creation), attempts to extract the last frame from + the most recent preview video. + + Args: + camera: Camera name + before: Optional timestamp to find frames before this time + ffmpeg_config: Optional ffmpeg configuration for extracting from video fallback + + Returns: + Path to a preview frame image file, or None if not available + """ + if not os.path.exists(PREVIEW_CACHE_DIR): + logger.debug(f"Preview cache directory does not exist: {PREVIEW_CACHE_DIR}") + else: + try: + # files are named preview_{camera}-{timestamp}.webp + # we want the largest timestamp that is less than or equal to before + preview_files = [ + f + for f in os.listdir(PREVIEW_CACHE_DIR) + if f.startswith(f"preview_{camera}-") + and f.endswith(f".{PREVIEW_FRAME_TYPE}") + ] + + if preview_files: + # sort by timestamp in descending order + # filenames are like preview_front-1712345678.901234.webp + preview_files.sort(reverse=True) + + if before is None: + return os.path.join(PREVIEW_CACHE_DIR, preview_files[0]) + + for file_name in preview_files: + try: + # Extract timestamp: preview_front-1712345678.901234.webp + # Split by dash and extension + timestamp_part = file_name.split("-")[-1].split( + f".{PREVIEW_FRAME_TYPE}" + )[0] + timestamp = float(timestamp_part) + + if timestamp <= before: + return os.path.join(PREVIEW_CACHE_DIR, file_name) + except (ValueError, IndexError): + continue + except Exception as e: + logger.error(f"Error searching for cached preview frames: {e}") + + # If no cached frames exist and ffmpeg_config is provided, try extracting from preview video + if ffmpeg_config is not None: + try: + logger.debug( + f"No cached preview frames found for {camera}, attempting to extract from preview video" + ) + + query = Previews.select().where(Previews.camera == camera) + + if before is not None: + query = query.where(Previews.end_time <= before) + + preview = query.order_by(Previews.end_time.desc()).limit(1).get() + + if preview and os.path.exists(preview.path): + logger.debug( + f"Found preview video for {camera}: {preview.path} (duration: {preview.duration}s)" + ) + + # Extract the last frame from the video + # Use webp for consistency with cached frames + image_data, error_msg = run_ffmpeg_snapshot( + ffmpeg_config, + preview.path, + codec="webp", + seek_time=max(0, preview.duration - 0.1), # Seek to near the end + height=PREVIEW_HEIGHT, + timeout=10, + ) + + if image_data: + # Write to a temporary file in the cache directory + # Use timestamp from the preview end time + temp_frame_path = os.path.join( + PREVIEW_CACHE_DIR, + f"preview_{camera}-{preview.end_time}_from_video.{PREVIEW_FRAME_TYPE}", + ) + + with open(temp_frame_path, "wb") as f: + f.write(image_data) + + logger.debug( + f"Successfully extracted last frame from preview video to {temp_frame_path}" + ) + return temp_frame_path + else: + logger.warning( + f"Failed to extract frame from preview video {preview.path}: {error_msg}" + ) + else: + if preview: + logger.warning(f"Preview video path does not exist: {preview.path}") + else: + logger.debug(f"No preview video found in database for {camera}") + + except DoesNotExist: + logger.debug(f"No preview video found in database for {camera}") + except Exception as e: + logger.error(f"Error extracting frame from preview video: {e}") + + return None + + class FFMpegConverter(threading.Thread): """Convert a list of still frames into a vfr mp4.""" From db485ddafa692f4cd84736805fc78327e0052914 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:48:08 -0600 Subject: [PATCH 2/5] remove frame extraction logic --- frigate/api/media.py | 3 +- frigate/output/preview.py | 154 ++++++++++---------------------------- 2 files changed, 39 insertions(+), 118 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index 45f320647..8b7a7da16 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -165,9 +165,8 @@ async def latest_frame( frame_processor.get_current_frame_time(camera_name) + retry_interval ): last_frame_time = frame_processor.get_current_frame_time(camera_name) - config: FrigateConfig = request.app.frigate_config preview_path = get_most_recent_preview_frame( - camera_name, before=last_frame_time, ffmpeg_config=config.ffmpeg + camera_name, before=last_frame_time ) if preview_path: diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 1493c1b73..f16bb3bd7 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -8,14 +8,13 @@ import subprocess as sp import threading import time from pathlib import Path -from typing import Any, Optional +from typing import Any import cv2 import numpy as np -from peewee import DoesNotExist from frigate.comms.inter_process import InterProcessRequestor -from frigate.config import CameraConfig, FfmpegConfig, RecordQualityEnum +from frigate.config import CameraConfig, RecordQualityEnum from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW, PREVIEW_FRAME_TYPE from frigate.ffmpeg_presets import ( FPS_VFR_PARAM, @@ -24,12 +23,7 @@ from frigate.ffmpeg_presets import ( ) from frigate.models import Previews from frigate.track.object_processing import TrackedObject -from frigate.util.image import ( - copy_yuv_to_position, - get_blank_yuv_frame, - get_yuv_crop, - run_ffmpeg_snapshot, -) +from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop logger = logging.getLogger(__name__) @@ -63,121 +57,49 @@ def get_cache_image_name(camera: str, frame_time: float) -> str: ) -def get_most_recent_preview_frame( - camera: str, before: float = None, ffmpeg_config: Optional[FfmpegConfig] = None -) -> str | None: - """Get the most recent preview frame for a camera. - - First tries to find a cached preview webp frame. If none exists (e.g., they've been - cleaned up after preview video creation), attempts to extract the last frame from - the most recent preview video. - - Args: - camera: Camera name - before: Optional timestamp to find frames before this time - ffmpeg_config: Optional ffmpeg configuration for extracting from video fallback - - Returns: - Path to a preview frame image file, or None if not available - """ +def get_most_recent_preview_frame(camera: str, before: float = None) -> str | None: + """Get the most recent preview frame for a camera.""" if not os.path.exists(PREVIEW_CACHE_DIR): - logger.debug(f"Preview cache directory does not exist: {PREVIEW_CACHE_DIR}") - else: - try: - # files are named preview_{camera}-{timestamp}.webp - # we want the largest timestamp that is less than or equal to before - preview_files = [ - f - for f in os.listdir(PREVIEW_CACHE_DIR) - if f.startswith(f"preview_{camera}-") - and f.endswith(f".{PREVIEW_FRAME_TYPE}") - ] + return None - if preview_files: - # sort by timestamp in descending order - # filenames are like preview_front-1712345678.901234.webp - preview_files.sort(reverse=True) + try: + # files are named preview_{camera}-{timestamp}.webp + # we want the largest timestamp that is less than or equal to before + preview_files = [ + f + for f in os.listdir(PREVIEW_CACHE_DIR) + if f.startswith(f"preview_{camera}-") + and f.endswith(f".{PREVIEW_FRAME_TYPE}") + ] - if before is None: - return os.path.join(PREVIEW_CACHE_DIR, preview_files[0]) + if not preview_files: + return None - for file_name in preview_files: - try: - # Extract timestamp: preview_front-1712345678.901234.webp - # Split by dash and extension - timestamp_part = file_name.split("-")[-1].split( - f".{PREVIEW_FRAME_TYPE}" - )[0] - timestamp = float(timestamp_part) + # sort by timestamp in descending order + # filenames are like preview_front-1712345678.901234.webp + preview_files.sort(reverse=True) - if timestamp <= before: - return os.path.join(PREVIEW_CACHE_DIR, file_name) - except (ValueError, IndexError): - continue - except Exception as e: - logger.error(f"Error searching for cached preview frames: {e}") + if before is None: + return os.path.join(PREVIEW_CACHE_DIR, preview_files[0]) - # If no cached frames exist and ffmpeg_config is provided, try extracting from preview video - if ffmpeg_config is not None: - try: - logger.debug( - f"No cached preview frames found for {camera}, attempting to extract from preview video" - ) + for file_name in preview_files: + try: + # Extract timestamp: preview_front-1712345678.901234.webp + # Split by dash and extension + timestamp_part = file_name.split("-")[-1].split( + f".{PREVIEW_FRAME_TYPE}" + )[0] + timestamp = float(timestamp_part) - query = Previews.select().where(Previews.camera == camera) + if timestamp <= before: + return os.path.join(PREVIEW_CACHE_DIR, file_name) + except (ValueError, IndexError): + continue - if before is not None: - query = query.where(Previews.end_time <= before) - - preview = query.order_by(Previews.end_time.desc()).limit(1).get() - - if preview and os.path.exists(preview.path): - logger.debug( - f"Found preview video for {camera}: {preview.path} (duration: {preview.duration}s)" - ) - - # Extract the last frame from the video - # Use webp for consistency with cached frames - image_data, error_msg = run_ffmpeg_snapshot( - ffmpeg_config, - preview.path, - codec="webp", - seek_time=max(0, preview.duration - 0.1), # Seek to near the end - height=PREVIEW_HEIGHT, - timeout=10, - ) - - if image_data: - # Write to a temporary file in the cache directory - # Use timestamp from the preview end time - temp_frame_path = os.path.join( - PREVIEW_CACHE_DIR, - f"preview_{camera}-{preview.end_time}_from_video.{PREVIEW_FRAME_TYPE}", - ) - - with open(temp_frame_path, "wb") as f: - f.write(image_data) - - logger.debug( - f"Successfully extracted last frame from preview video to {temp_frame_path}" - ) - return temp_frame_path - else: - logger.warning( - f"Failed to extract frame from preview video {preview.path}: {error_msg}" - ) - else: - if preview: - logger.warning(f"Preview video path does not exist: {preview.path}") - else: - logger.debug(f"No preview video found in database for {camera}") - - except DoesNotExist: - logger.debug(f"No preview video found in database for {camera}") - except Exception as e: - logger.error(f"Error extracting frame from preview video: {e}") - - return None + return None + except Exception as e: + logger.error(f"Error searching for most recent preview frame: {e}") + return None class FFMpegConverter(threading.Thread): From b569f30820c4e1c6dfc438daa4dcc471db75bf67 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 22 Jan 2026 10:48:20 -0600 Subject: [PATCH 3/5] tests --- .../test/http_api/test_http_latest_frame.py | 107 ++++++++++++++++++ frigate/test/test_preview_loader.py | 80 +++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 frigate/test/http_api/test_http_latest_frame.py create mode 100644 frigate/test/test_preview_loader.py diff --git a/frigate/test/http_api/test_http_latest_frame.py b/frigate/test/http_api/test_http_latest_frame.py new file mode 100644 index 000000000..755ee6eb1 --- /dev/null +++ b/frigate/test/http_api/test_http_latest_frame.py @@ -0,0 +1,107 @@ +import os +import shutil +from unittest.mock import MagicMock + +import cv2 +import numpy as np + +from frigate.output.preview import PREVIEW_CACHE_DIR, PREVIEW_FRAME_TYPE +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpLatestFrame(BaseTestHttp): + def setUp(self): + super().setUp([]) + self.app = super().create_app() + self.app.detected_frames_processor = MagicMock() + + if os.path.exists(PREVIEW_CACHE_DIR): + shutil.rmtree(PREVIEW_CACHE_DIR) + os.makedirs(PREVIEW_CACHE_DIR) + + def tearDown(self): + if os.path.exists(PREVIEW_CACHE_DIR): + shutil.rmtree(PREVIEW_CACHE_DIR) + super().tearDown() + + def test_latest_frame_fallback_to_preview(self): + camera = "front_door" + # 1. Mock frame processor to return None (simulating offline/missing frame) + self.app.detected_frames_processor.get_current_frame.return_value = None + # Return a timestamp that is after our dummy preview frame + self.app.detected_frames_processor.get_current_frame_time.return_value = ( + 1234567891.0 + ) + + # 2. Create a dummy preview file + dummy_frame = np.zeros((180, 320, 3), np.uint8) + cv2.putText( + dummy_frame, + "PREVIEW", + (50, 50), + cv2.FONT_HERSHEY_SIMPLEX, + 1, + (255, 255, 255), + 2, + ) + preview_path = os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-1234567890.0.{PREVIEW_FRAME_TYPE}" + ) + cv2.imwrite(preview_path, dummy_frame) + + with AuthTestClient(self.app) as client: + response = client.get(f"/{camera}/latest.webp") + assert response.status_code == 200 + assert response.headers.get("X-Frigate-Offline") == "true" + # Verify we got an image (webp) + assert response.headers.get("content-type") == "image/webp" + + def test_latest_frame_no_fallback_when_live(self): + camera = "front_door" + # 1. Mock frame processor to return a live frame + dummy_frame = np.zeros((180, 320, 3), np.uint8) + self.app.detected_frames_processor.get_current_frame.return_value = dummy_frame + self.app.detected_frames_processor.get_current_frame_time.return_value = ( + 2000000000.0 # Way in the future + ) + + with AuthTestClient(self.app) as client: + response = client.get(f"/{camera}/latest.webp") + assert response.status_code == 200 + assert "X-Frigate-Offline" not in response.headers + + def test_latest_frame_stale_falls_back_to_preview(self): + camera = "front_door" + # 1. Mock frame processor to return a stale frame + dummy_frame = np.zeros((180, 320, 3), np.uint8) + self.app.detected_frames_processor.get_current_frame.return_value = dummy_frame + # Return a timestamp that is after our dummy preview frame, but way in the past + self.app.detected_frames_processor.get_current_frame_time.return_value = 1000.0 + + # 2. Create a dummy preview file + preview_path = os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-999.0.{PREVIEW_FRAME_TYPE}" + ) + cv2.imwrite(preview_path, dummy_frame) + + with AuthTestClient(self.app) as client: + response = client.get(f"/{camera}/latest.webp") + assert response.status_code == 200 + assert response.headers.get("X-Frigate-Offline") == "true" + + def test_latest_frame_no_preview_found(self): + camera = "front_door" + # 1. Mock frame processor to return None + self.app.detected_frames_processor.get_current_frame.return_value = None + + # 2. No preview file created + + with AuthTestClient(self.app) as client: + response = client.get(f"/{camera}/latest.webp") + # Should fall back to camera-error.jpg (which might not exist in test env, but let's see) + # If camera-error.jpg is not found, it returns 500 "Unable to get valid frame" in latest_frame + # OR it uses request.app.camera_error_image if already loaded. + + # Since we didn't provide camera-error.jpg, it might 500 if glob fails or return 500 if frame is None. + assert response.status_code in [200, 500] + assert "X-Frigate-Offline" not in response.headers diff --git a/frigate/test/test_preview_loader.py b/frigate/test/test_preview_loader.py new file mode 100644 index 000000000..e2062fce1 --- /dev/null +++ b/frigate/test/test_preview_loader.py @@ -0,0 +1,80 @@ +import os +import shutil +import unittest + +from frigate.output.preview import ( + PREVIEW_CACHE_DIR, + PREVIEW_FRAME_TYPE, + get_most_recent_preview_frame, +) + + +class TestPreviewLoader(unittest.TestCase): + def setUp(self): + if os.path.exists(PREVIEW_CACHE_DIR): + shutil.rmtree(PREVIEW_CACHE_DIR) + os.makedirs(PREVIEW_CACHE_DIR) + + def tearDown(self): + if os.path.exists(PREVIEW_CACHE_DIR): + shutil.rmtree(PREVIEW_CACHE_DIR) + + def test_get_most_recent_preview_frame_missing(self): + self.assertIsNone(get_most_recent_preview_frame("test_camera")) + + def test_get_most_recent_preview_frame_exists(self): + camera = "test_camera" + # create dummy preview files + for ts in ["1000.0", "2000.0", "1500.0"]: + with open( + os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" + ), + "w", + ) as f: + f.write(f"test_{ts}") + + expected_path = os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-2000.0.{PREVIEW_FRAME_TYPE}" + ) + self.assertEqual(get_most_recent_preview_frame(camera), expected_path) + + def test_get_most_recent_preview_frame_before(self): + camera = "test_camera" + # create dummy preview files + for ts in ["1000.0", "2000.0"]: + with open( + os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-{ts}.{PREVIEW_FRAME_TYPE}" + ), + "w", + ) as f: + f.write(f"test_{ts}") + + # Test finding frame before or at 1500 + expected_path = os.path.join( + PREVIEW_CACHE_DIR, f"preview_{camera}-1000.0.{PREVIEW_FRAME_TYPE}" + ) + self.assertEqual( + get_most_recent_preview_frame(camera, before=1500.0), expected_path + ) + + # Test finding frame before or at 999 + self.assertIsNone(get_most_recent_preview_frame(camera, before=999.0)) + + def test_get_most_recent_preview_frame_other_camera(self): + camera = "test_camera" + other_camera = "other_camera" + with open( + os.path.join( + PREVIEW_CACHE_DIR, f"preview_{other_camera}-3000.0.{PREVIEW_FRAME_TYPE}" + ), + "w", + ) as f: + f.write("test") + + self.assertIsNone(get_most_recent_preview_frame(camera)) + + def test_get_most_recent_preview_frame_no_directory(self): + shutil.rmtree(PREVIEW_CACHE_DIR) + self.assertIsNone(get_most_recent_preview_frame("test_camera")) From b2ceb15db4be6e7f2a9ff0783b86c6eb618f0a93 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:07:26 -0600 Subject: [PATCH 4/5] frontend --- web/src/components/player/LivePlayer.tsx | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 9500688f5..38976d450 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -81,6 +81,11 @@ export default function LivePlayer({ const internalContainerRef = useRef(null); const cameraName = useCameraFriendlyName(cameraConfig); + + // player is showing on a dashboard if containerRef is not provided + + const inDashboard = containerRef?.current == null; + // stats const [stats, setStats] = useState({ @@ -408,6 +413,28 @@ export default function LivePlayer({ /> + {offline && inDashboard && ( + <> +
+
+
+
{t("streamOffline.title")}
+ +

+ + streamOffline.desc + +

+
+
+ + )} + {offline && !showStillWithoutActivity && cameraEnabled && (
From f589a60cdebbc915709d21240a0ae9a7ed23a5e0 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 22 Jan 2026 11:11:08 -0600 Subject: [PATCH 5/5] add description to api endpoint --- frigate/api/media.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index 8b7a7da16..4adf3f890 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -126,7 +126,9 @@ async def camera_ptz_info(request: Request, camera_name: str): @router.get( - "/{camera_name}/latest.{extension}", dependencies=[Depends(require_camera_access)] + "/{camera_name}/latest.{extension}", + dependencies=[Depends(require_camera_access)], + description="Returns the latest frame from the specified camera in the requested format (jpg, png, webp). Falls back to preview frames if the camera is offline.", ) async def latest_frame( request: Request,