From e6601d50a613719adb361a4c2a715d4bc045a047 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:16:41 -0500 Subject: [PATCH] Add recording keyframe analysis to camera probe dialog (#23453) * backend: endpoint and util funcs * tests * frontend and i18n * update openapi spec * add tip to docs --- docs/docs/troubleshooting/recordings.md | 6 + docs/static/frigate-api.yaml | 29 +++ frigate/api/camera.py | 50 ++++- .../http_api/test_http_keyframe_analysis.py | 58 ++++++ frigate/test/test_keyframe_analysis.py | 111 ++++++++++ frigate/util/builtin.py | 23 ++- frigate/util/services.py | 125 ++++++++++++ frigate/video/ffmpeg.py | 21 +- web/public/locales/en/views/system.json | 15 ++ .../components/overlay/CameraInfoDialog.tsx | 17 +- .../overlay/KeyframeAnalysisSection.tsx | 193 ++++++++++++++++++ web/src/types/stats.ts | 19 ++ 12 files changed, 642 insertions(+), 25 deletions(-) create mode 100644 frigate/test/http_api/test_http_keyframe_analysis.py create mode 100644 frigate/test/test_keyframe_analysis.py create mode 100644 web/src/components/overlay/KeyframeAnalysisSection.tsx diff --git a/docs/docs/troubleshooting/recordings.md b/docs/docs/troubleshooting/recordings.md index 2425e653a4..83f00d1ad0 100644 --- a/docs/docs/troubleshooting/recordings.md +++ b/docs/docs/troubleshooting/recordings.md @@ -121,6 +121,12 @@ If segments are only ~1 second instead of ~10 seconds, the camera is sending cor - **Changing codec, bitrate, or resolution mid-stream** — Any encoding changes during an active stream can cause unpredictable segment splitting. - **Camera firmware bugs** — Check for firmware updates from your camera manufacturer. +:::tip + +You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the System → Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce. + +::: + ### Step 4: Check for a stuck detector If the detect stream is not processing frames, segments will accumulate. Common causes: diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 146fce4e64..2c6109b985 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -400,6 +400,35 @@ paths: application/json: schema: $ref: "#/components/schemas/HTTPValidationError" + /keyframe_analysis: + get: + tags: + - Camera + summary: Keyframe Analysis + description: >- + Probe a camera's record stream and classify its keyframe spacing. + Detects smart/+ codecs and long/variable GOPs that degrade recording. + operationId: keyframe_analysis_keyframe_analysis_get + parameters: + - name: camera + in: query + required: false + schema: + type: string + default: "" + title: Camera + responses: + "200": + description: Successful Response + content: + application/json: + schema: {} + "422": + description: Validation Error + content: + application/json: + schema: + $ref: "#/components/schemas/HTTPValidationError" /ffprobe/snapshot: get: tags: diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 339ee33a16..f54b859454 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -34,11 +34,15 @@ from frigate.config.camera.updater import ( ) from frigate.config.env import substitute_frigate_vars from frigate.models import User -from frigate.util.builtin import clean_camera_user_pass +from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.config import find_config_file from frigate.util.image import run_ffmpeg_snapshot -from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source +from frigate.util.services import ( + analyze_record_keyframes, + ffprobe_stream, + is_restricted_go2rtc_source, +) logger = logging.getLogger(__name__) @@ -362,6 +366,48 @@ def ffprobe(request: Request, paths: str = "", detailed: bool = False): return JSONResponse(content=output) +@router.get("/keyframe_analysis", dependencies=[Depends(require_role(["admin"]))]) +async def keyframe_analysis(request: Request, camera: str = ""): + """Probe a camera's record stream and classify its keyframe spacing. + + Detects smart/+ codecs and long/variable GOPs that degrade recording. + """ + config: FrigateConfig = request.app.frigate_config + + if camera not in config.cameras: + return JSONResponse( + content={"success": False, "message": f"{camera} is not a valid camera."}, + status_code=404, + ) + + camera_config = config.cameras[camera] + + if not camera_config.enabled: + return JSONResponse( + content={"success": False, "message": f"{camera} is not enabled."}, + status_code=404, + ) + + # keyframe spacing only matters when this camera is recording + if not camera_config.record.enabled: + return JSONResponse(content={"severity": "record_disabled"}) + + # recording guarantees an input carries the record role; its index matches + # the "Stream N" numbering the ffprobe endpoint surfaces (same input order) + record_index, record_input = next( + (idx, i) + for idx, i in enumerate(camera_config.ffmpeg.inputs) + if "record" in i.roles + ) + + segment_time = get_record_segment_time(camera_config) + result = await analyze_record_keyframes( + config.ffmpeg, record_input.path, segment_time + ) + result["stream_index"] = record_index + return JSONResponse(content=result) + + @router.get("/ffprobe/snapshot", dependencies=[Depends(require_role(["admin"]))]) def ffprobe_snapshot(request: Request, url: str = "", timeout: int = 10): """Get a snapshot from a stream URL using ffmpeg.""" diff --git a/frigate/test/http_api/test_http_keyframe_analysis.py b/frigate/test/http_api/test_http_keyframe_analysis.py new file mode 100644 index 0000000000..6a49e105ec --- /dev/null +++ b/frigate/test/http_api/test_http_keyframe_analysis.py @@ -0,0 +1,58 @@ +from unittest.mock import AsyncMock, patch + +from frigate.models import Event, Recordings, ReviewSegment +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpKeyframeAnalysis(BaseTestHttp): + def setUp(self): + super().setUp([Event, Recordings, ReviewSegment]) + + def test_invalid_camera_returns_404(self): + app = super().create_app() + with AuthTestClient(app) as client: + response = client.get("/keyframe_analysis?camera=does_not_exist") + assert response.status_code == 404 + + def test_record_disabled_returns_neutral(self): + # default minimal_config has recording disabled + app = super().create_app() + with AuthTestClient(app) as client: + response = client.get("/keyframe_analysis?camera=front_door") + assert response.status_code == 200 + assert response.json()["severity"] == "record_disabled" + + def test_probes_record_input_and_returns_severity(self): + self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [ + { + "path": "rtsp://10.0.0.1:554/record", + "roles": ["detect", "record"], + } + ] + self.minimal_config["cameras"]["front_door"]["record"] = {"enabled": True} + app = super().create_app() + + canned = { + "severity": "ok", + "keyframe_count": 5, + "max_gap": 1.0, + "mean_gap": 1.0, + "min_gap": 1.0, + "segment_time": 10, + "duration_observed": 4.0, + "thresholds": {"warning": 4.0, "error": 10}, + } + + with patch( + "frigate.api.camera.analyze_record_keyframes", + AsyncMock(return_value=canned), + ) as mock_probe: + with AuthTestClient(app) as client: + response = client.get("/keyframe_analysis?camera=front_door") + + assert response.status_code == 200 + assert response.json()["severity"] == "ok" + # index matches the input carrying the record role ("Stream 1") + assert response.json()["stream_index"] == 0 + # the record-role input path was probed + assert mock_probe.await_args.args[1] == "rtsp://10.0.0.1:554/record" diff --git a/frigate/test/test_keyframe_analysis.py b/frigate/test/test_keyframe_analysis.py new file mode 100644 index 0000000000..85302b5659 --- /dev/null +++ b/frigate/test/test_keyframe_analysis.py @@ -0,0 +1,111 @@ +"""Tests for keyframe-spacing analysis used to detect smart/+ codecs.""" + +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from frigate.util.services import ( + analyze_record_keyframes, + classify_keyframe_gaps, + parse_keyframe_packets, +) + + +class TestClassifyKeyframeGaps(unittest.TestCase): + def test_ok_when_gaps_small(self): + # keyframes every ~1s + pts = [0.0, 1.0, 2.0, 3.0, 4.0] + result = classify_keyframe_gaps(pts, segment_time=10) + self.assertEqual(result["severity"], "ok") + self.assertEqual(result["max_gap"], 1.0) + self.assertEqual(result["keyframe_count"], 5) + self.assertEqual(result["thresholds"], {"warning": 4.0, "error": 10}) + + def test_warning_when_gap_exceeds_four_seconds(self): + pts = [0.0, 1.0, 6.5] # 5.5s gap + result = classify_keyframe_gaps(pts, segment_time=10) + self.assertEqual(result["severity"], "warning") + self.assertEqual(result["max_gap"], 5.5) + + def test_error_when_gap_exceeds_segment_time(self): + pts = [0.0, 12.0] # 12s gap > 10s segment + result = classify_keyframe_gaps(pts, segment_time=10) + self.assertEqual(result["severity"], "error") + + def test_error_threshold_tracks_segment_time(self): + pts = [0.0, 6.0] # 6s gap, segment_time=5 -> error + result = classify_keyframe_gaps(pts, segment_time=5) + self.assertEqual(result["severity"], "error") + + def test_unknown_with_single_keyframe(self): + result = classify_keyframe_gaps([1.0], segment_time=10) + self.assertEqual(result["severity"], "unknown") + self.assertIsNone(result["max_gap"]) + self.assertEqual(result["keyframe_count"], 1) + + def test_unknown_with_no_keyframes(self): + result = classify_keyframe_gaps([], segment_time=10) + self.assertEqual(result["severity"], "unknown") + self.assertEqual(result["keyframe_count"], 0) + + +class TestParseKeyframePackets(unittest.TestCase): + def test_extracts_keyframe_pts_and_max(self): + output = "0.000000,K__\n0.033333,___\n1.000000,K__\n1.500000,___\n" + keyframe_pts, max_pts = parse_keyframe_packets(output) + self.assertEqual(keyframe_pts, [0.0, 1.0]) + self.assertEqual(max_pts, 1.5) + + def test_skips_unparseable_and_empty_lines(self): + output = "N/A,K__\n\n2.0,K__\nbad line\n" + keyframe_pts, max_pts = parse_keyframe_packets(output) + self.assertEqual(keyframe_pts, [2.0]) + self.assertEqual(max_pts, 2.0) + + def test_empty_output(self): + keyframe_pts, max_pts = parse_keyframe_packets("") + self.assertEqual(keyframe_pts, []) + self.assertIsNone(max_pts) + + +class TestAnalyzeRecordKeyframes(unittest.IsolatedAsyncioTestCase): + async def test_merges_duration_and_classification(self): + csv = b"0.0,K__\n1.0,___\n6.0,K__\n7.0,___\n" + proc = MagicMock() + proc.communicate = AsyncMock(return_value=(csv, b"")) + ffmpeg = MagicMock() + ffmpeg.ffprobe_path = "/usr/bin/ffprobe" + + with patch( + "frigate.util.services.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ): + result = await analyze_record_keyframes( + ffmpeg, "rtsp://cam/stream", segment_time=10 + ) + + self.assertEqual(result["severity"], "warning") # 6s gap > 4s + self.assertEqual(result["max_gap"], 6.0) + self.assertEqual(result["duration_observed"], 7.0) + + async def test_timeout_returns_unknown(self): + proc = MagicMock() + proc.communicate = AsyncMock(side_effect=asyncio.TimeoutError()) + proc.kill = MagicMock() + ffmpeg = MagicMock() + ffmpeg.ffprobe_path = "/usr/bin/ffprobe" + + with patch( + "frigate.util.services.asyncio.create_subprocess_exec", + AsyncMock(return_value=proc), + ): + result = await analyze_record_keyframes( + ffmpeg, "rtsp://cam/stream", segment_time=10 + ) + + self.assertEqual(result["severity"], "unknown") + proc.kill.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index bd45a4a1f1..c1c9d9d6ea 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -14,13 +14,16 @@ import urllib.parse from collections.abc import Mapping from multiprocessing.managers import ValueProxy from pathlib import Path -from typing import Any, Dict, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union import numpy as np from ruamel.yaml import YAML from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS +if TYPE_CHECKING: + from frigate.config import CameraConfig + logger = logging.getLogger(__name__) @@ -132,6 +135,24 @@ def get_ffmpeg_arg_list(arg: Any) -> list: return arg if isinstance(arg, list) else shlex.split(arg) +# all built-in record presets use this segment_time +DEFAULT_RECORD_SEGMENT_TIME = 10 + + +def get_record_segment_time(config: "CameraConfig") -> int: + """Extract -segment_time from the camera's record output args.""" + record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record) + + if record_args and record_args[0].startswith("preset"): + return DEFAULT_RECORD_SEGMENT_TIME + + try: + idx = record_args.index("-segment_time") + return int(record_args[idx + 1]) + except (ValueError, IndexError): + return DEFAULT_RECORD_SEGMENT_TIME + + def load_labels( path: Optional[str], encoding="utf-8", prefill=91, indexed: bool | None = None ): diff --git a/frigate/util/services.py b/frigate/util/services.py index 0445053b8a..3938dff884 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -879,6 +879,131 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro return result +KEYFRAME_PROBE_WINDOW_SECONDS = 20 +KEYFRAME_GAP_WARNING_SECONDS = 4.0 + + +def parse_keyframe_packets(output: str) -> Tuple[List[float], Optional[float]]: + """Parse ffprobe CSV `pts_time,flags` output. + + Returns the presentation timestamps of keyframes (flags containing "K") + and the maximum timestamp observed across all packets. + """ + keyframe_pts: List[float] = [] + max_pts: Optional[float] = None + + for line in output.splitlines(): + parts = line.split(",") + if len(parts) < 2: + continue + try: + pts = float(parts[0]) + except ValueError: + continue + if max_pts is None or pts > max_pts: + max_pts = pts + if "K" in parts[1]: + keyframe_pts.append(pts) + + return keyframe_pts, max_pts + + +def classify_keyframe_gaps( + keyframe_pts: List[float], segment_time: int +) -> dict[str, Any]: + """Classify keyframe spacing for recording suitability. + + A camera using a smart/+ codec or a long/variable GOP produces large or + irregular gaps between keyframes, which breaks time-based recording + segmentation. Severity: + - "unknown" when fewer than two keyframes were observed + - "error" when the longest gap exceeds the record segment length + - "warning" when the longest gap exceeds the warning threshold + - "ok" otherwise + """ + thresholds = { + "warning": KEYFRAME_GAP_WARNING_SECONDS, + "error": segment_time, + } + + if len(keyframe_pts) < 2: + return { + "keyframe_count": len(keyframe_pts), + "max_gap": None, + "mean_gap": None, + "min_gap": None, + "segment_time": segment_time, + "severity": "unknown", + "thresholds": thresholds, + } + + gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])] + max_gap = max(gaps) + + if max_gap > segment_time: + severity = "error" + elif max_gap > KEYFRAME_GAP_WARNING_SECONDS: + severity = "warning" + else: + severity = "ok" + + return { + "keyframe_count": len(keyframe_pts), + "max_gap": round(max_gap, 2), + "mean_gap": round(sum(gaps) / len(gaps), 2), + "min_gap": round(min(gaps), 2), + "segment_time": segment_time, + "severity": severity, + "thresholds": thresholds, + } + + +async def analyze_record_keyframes( + ffmpeg, url: str, segment_time: int, window: int = KEYFRAME_PROBE_WINDOW_SECONDS +) -> dict[str, Any]: + """Probe a stream for ~`window` seconds and classify its keyframe spacing. + + Reads video packet flags via ffprobe to find keyframes, then measures the + gaps between them. On timeout or failure returns an "unknown" result rather + than a false all-clear. + """ + clean_url = escape_special_characters(url) + cmd = [ + ffmpeg.ffprobe_path, + "-v", + "error", + "-select_streams", + "v:0", + "-read_intervals", + f"%+{window}", + "-show_entries", + "packet=pts_time,flags", + "-of", + "csv=p=0", + clean_url, + ] + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=window + 15) + except asyncio.TimeoutError: + logger.warning("Keyframe probe timed out for record stream") + proc.kill() + return classify_keyframe_gaps([], segment_time) + except OSError as err: + logger.error("Keyframe probe failed: %s", err) + return classify_keyframe_gaps([], segment_time) + + keyframe_pts, max_pts = parse_keyframe_packets(stdout.decode("utf-8", "replace")) + result = classify_keyframe_gaps(keyframe_pts, segment_time) + result["duration_observed"] = round(max_pts, 2) if max_pts is not None else None + return result + + def vainfo_hwaccel(device_name: Optional[str] = None) -> sp.CompletedProcess: """Run vainfo.""" if not device_name: diff --git a/frigate/video/ffmpeg.py b/frigate/video/ffmpeg.py index e77c03b5e5..40b18b7da3 100644 --- a/frigate/video/ffmpeg.py +++ b/frigate/video/ffmpeg.py @@ -24,7 +24,7 @@ from frigate.config.camera.updater import ( ) from frigate.const import PROCESS_PRIORITY_HIGH from frigate.log import LogPipe -from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list +from frigate.util.builtin import EventsPerSecond, get_record_segment_time from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg from frigate.util.image import ( FrameManager, @@ -34,23 +34,6 @@ from frigate.util.process import FrigateProcess logger = logging.getLogger(__name__) -# all built-in record presets use this segment_time -DEFAULT_RECORD_SEGMENT_TIME = 10 - - -def _get_record_segment_time(config: CameraConfig) -> int: - """Extract -segment_time from the camera's record output args.""" - record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record) - - if record_args and record_args[0].startswith("preset"): - return DEFAULT_RECORD_SEGMENT_TIME - - try: - idx = record_args.index("-segment_time") - return int(record_args[idx + 1]) - except (ValueError, IndexError): - return DEFAULT_RECORD_SEGMENT_TIME - def capture_frames( ffmpeg_process: sp.Popen[Any], @@ -185,7 +168,7 @@ class CameraWatchdog(threading.Thread): # `valid` segments are published with the segment's start time, so the # gap between consecutive publishes can reach 2 * segment_time. Pad the # staleness threshold so it's never tighter than that worst case. - segment_time = _get_record_segment_time(self.config) + segment_time = get_record_segment_time(self.config) self.record_stale_threshold = max(120, 2 * segment_time + 30) # Stall tracking (based on last processed frame) diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index b824e0749c..9f387a7f30 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -174,6 +174,21 @@ "error": "Error: {{error}}", "tips": { "title": "Camera Probe Info" + }, + "keyframes": { + "title": "Keyframe analysis", + "analyzing": "Analyzing keyframes... {{seconds}} seconds remaining", + "stillAnalyzing": "Still analyzing keyframes...", + "recordStream": "Record stream:", + "keyframeCount": "Keyframes observed:", + "observedDuration": "Observed duration:", + "gap": "Keyframe gap (min / avg / max):", + "segmentLength": "Recording segment length:", + "ok": "Keyframes every ~{{seconds}}s, good for recording and playback.", + "warning": "Sparse or variable keyframes (longest gap ~{{seconds}}s), likely a smart codec (H.264+/H.265+), this is not recommended.", + "error": "Keyframe gap (~{{seconds}}s) exceeds the recording segment length ({{segmentTime}}s). Some segments may have no keyframe, which breaks playback. Disable the smart/+ codec on the camera or shorten its keyframe interval.", + "unknown": "Couldn't determine keyframe spacing.", + "recordDisabled": "Recording is disabled for this camera." } }, "framesAndDetections": "Frames / Detections", diff --git a/web/src/components/overlay/CameraInfoDialog.tsx b/web/src/components/overlay/CameraInfoDialog.tsx index fce1f6fd02..14755a2015 100644 --- a/web/src/components/overlay/CameraInfoDialog.tsx +++ b/web/src/components/overlay/CameraInfoDialog.tsx @@ -7,7 +7,8 @@ import { DialogTitle, } from "../ui/dialog"; import ActivityIndicator from "../indicators/activity-indicator"; -import { Ffprobe } from "@/types/stats"; +import KeyframeAnalysisSection from "./KeyframeAnalysisSection"; +import { Ffprobe, KeyframeAnalysis } from "@/types/stats"; import { Button } from "../ui/button"; import copy from "copy-to-clipboard"; import { CameraConfig } from "@/types/frigateConfig"; @@ -30,6 +31,7 @@ export default function CameraInfoDialog({ }: CameraInfoDialogProps) { const { t } = useTranslation(["views/system"]); const [ffprobeInfo, setFfprobeInfo] = useState(); + const [keyframeInfo, setKeyframeInfo] = useState(); useEffect(() => { axios @@ -67,7 +69,12 @@ export default function CameraInfoDialog({ }, []); const onCopyFfprobe = async () => { - copy(JSON.stringify(ffprobeInfo)); + copy( + JSON.stringify({ + ffprobe: ffprobeInfo, + keyframe_analysis: keyframeInfo, + }), + ); toast.success(t("cameras.toast.success.copyToClipboard")); }; @@ -96,7 +103,7 @@ export default function CameraInfoDialog({ cameras.info.streamDataFromFFPROBE -
+
{ffprobeInfo ? (
{ffprobeInfo.map((stream, idx) => ( @@ -184,6 +191,10 @@ export default function CameraInfoDialog({ )}
))} +
) : (
diff --git a/web/src/components/overlay/KeyframeAnalysisSection.tsx b/web/src/components/overlay/KeyframeAnalysisSection.tsx new file mode 100644 index 0000000000..299c2468e4 --- /dev/null +++ b/web/src/components/overlay/KeyframeAnalysisSection.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import axios from "axios"; +import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6"; +import { LuX } from "react-icons/lu"; +import ActivityIndicator from "../indicators/activity-indicator"; +import { KeyframeAnalysis } from "@/types/stats"; + +const PROBE_WINDOW_SECONDS = 20; + +type KeyframeAnalysisSectionProps = { + cameraName: string; + onResult?: (analysis: KeyframeAnalysis) => void; +}; + +export default function KeyframeAnalysisSection({ + cameraName, + onResult, +}: KeyframeAnalysisSectionProps) { + const { t } = useTranslation(["views/system"]); + const [analysis, setAnalysis] = useState(); + const [failed, setFailed] = useState(false); + const [secondsRemaining, setSecondsRemaining] = + useState(PROBE_WINDOW_SECONDS); + + // fire the probe once on mount + useEffect(() => { + let active = true; + axios + .get("keyframe_analysis", { params: { camera: cameraName } }) + .then((res) => { + if (active) { + setAnalysis(res.data); + onResult?.(res.data); + } + }) + .catch(() => { + if (active) { + setFailed(true); + } + }); + return () => { + active = false; + }; + // re-probing only depends on the camera; onResult is a stable setter + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cameraName]); + + // countdown while waiting for the probe to return + useEffect(() => { + if (analysis || failed) { + return; + } + const interval = setInterval(() => { + setSecondsRemaining((s) => (s > 0 ? s - 1 : 0)); + }, 1000); + return () => clearInterval(interval); + }, [analysis, failed]); + + const content = useMemo(() => { + if (failed) { + return {t("cameras.info.keyframes.unknown")}; + } + + if (!analysis) { + return ( +
+ + + {secondsRemaining > 0 + ? t("cameras.info.keyframes.analyzing", { + seconds: secondsRemaining, + }) + : t("cameras.info.keyframes.stillAnalyzing")} + +
+ ); + } + + let summary; + switch (analysis.severity) { + case "ok": + summary = ( + + {t("cameras.info.keyframes.ok", { seconds: analysis.mean_gap })} + + ); + break; + case "warning": + summary = ( + + {t("cameras.info.keyframes.warning", { seconds: analysis.max_gap })} + + ); + break; + case "error": + summary = ( + + {t("cameras.info.keyframes.error", { + seconds: analysis.max_gap, + segmentTime: analysis.segment_time, + })} + + ); + break; + case "record_disabled": + summary = ( + {t("cameras.info.keyframes.recordDisabled")} + ); + break; + default: + summary = ( + {t("cameras.info.keyframes.unknown")} + ); + } + + // gap statistics are only meaningful once at least two keyframes were seen + const hasStats = analysis.max_gap != null; + const hasDetails = hasStats || analysis.stream_index != null; + + return ( +
+ {analysis.stream_index != null && ( +
+ {t("cameras.info.keyframes.recordStream")}{" "} + + {t("cameras.info.stream", { idx: analysis.stream_index + 1 })} + +
+ )} + {hasStats && ( +
+
+ {t("cameras.info.keyframes.keyframeCount")}{" "} + {analysis.keyframe_count} +
+
+ {t("cameras.info.keyframes.observedDuration")}{" "} + + {analysis.duration_observed}s + +
+
+ {t("cameras.info.keyframes.gap")}{" "} + + {analysis.min_gap}s / {analysis.mean_gap}s / {analysis.max_gap}s + +
+
+ {t("cameras.info.keyframes.segmentLength")}{" "} + {analysis.segment_time}s +
+
+ )} +
{summary}
+
+ ); + }, [analysis, failed, secondsRemaining, t]); + + return ( +
+
+ {t("cameras.info.keyframes.title")} +
+
{content}
+
+ ); +} + +type RowProps = { + icon: "ok" | "warning" | "error" | "unknown"; + children: React.ReactNode; +}; + +function Row({ icon, children }: RowProps) { + return ( +
+ {icon === "ok" && ( + + )} + {icon === "warning" && ( + + )} + {icon === "error" && ( + + )} + {icon === "unknown" && ( + + )} + {children} +
+ ); +} diff --git a/web/src/types/stats.ts b/web/src/types/stats.ts index 0ebac9ebd3..151e705004 100644 --- a/web/src/types/stats.ts +++ b/web/src/types/stats.ts @@ -135,3 +135,22 @@ export type Ffprobe = { }[]; }; }; + +export type KeyframeSeverity = + | "ok" + | "warning" + | "error" + | "unknown" + | "record_disabled"; + +export type KeyframeAnalysis = { + severity: KeyframeSeverity; + stream_index?: number; + keyframe_count?: number; + max_gap?: number | null; + mean_gap?: number | null; + min_gap?: number | null; + duration_observed?: number | null; + segment_time?: number; + thresholds?: { warning: number; error: number }; +};