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 }; +};