Compare commits

..

11 Commits

Author SHA1 Message Date
Weblate (bot)
71c29ff0fa
Merge 6a6c5b7389 into f3a352ef3f 2026-06-11 08:32:16 +00:00
Hosted Weblate
6a6c5b7389
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1276 of 1276 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Yechi Yang <yechiyang93@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/zh_Hans/
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/views-settings
2026-06-11 10:32:10 +02:00
Hosted Weblate
db57a08a1f
Translated using Weblate (Swedish)
Currently translated at 50.7% (648 of 1277 strings)

Translated using Weblate (Swedish)

Currently translated at 0.1% (1 of 809 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (Swedish)

Currently translated at 0.6% (3 of 475 strings)

Translated using Weblate (Swedish)

Currently translated at 94.4% (137 of 145 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (26 of 26 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (101 of 101 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (239 of 239 strings)

Co-authored-by: Christian Bengtsson <bnccnb@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Kristian Johansson <knmjohansson@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-player/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-explore/sv/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/sv/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/components-player
Translation: Frigate NVR/views-explore
Translation: Frigate NVR/views-settings
2026-06-11 10:32:09 +02:00
Hosted Weblate
550c6e4254
Translated using Weblate (French)
Currently translated at 56.4% (35 of 62 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: LeBuzzy <bwinster2@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/fr/
Translation: Frigate NVR/views-motionSearch
2026-06-11 10:32:09 +02:00
Hosted Weblate
d1618b763d
Translated using Weblate (Indonesian)
Currently translated at 43.5% (44 of 101 strings)

Translated using Weblate (Indonesian)

Currently translated at 94.0% (47 of 50 strings)

Translated using Weblate (Indonesian)

Currently translated at 42.5% (43 of 101 strings)

Translated using Weblate (Indonesian)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Indonesian)

Currently translated at 90.0% (45 of 50 strings)

Translated using Weblate (Indonesian)

Currently translated at 90.0% (45 of 50 strings)

Co-authored-by: Alberto-Audrix <alberto.suiwidjaya6@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Naufal F <fadhlurrahmannf0812@gmail.com>
Co-authored-by: Yeni Setiawan <yenisetiawan@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/id/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/id/
Translation: Frigate NVR/common
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/components-dialog
2026-06-11 10:32:09 +02:00
Hosted Weblate
f23a684f58
Translated using Weblate (Italian)
Currently translated at 100.0% (1276 of 1276 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (100 of 100 strings)

Translated using Weblate (Italian)

Currently translated at 67.7% (548 of 809 strings)

Translated using Weblate (Italian)

Currently translated at 55.7% (451 of 809 strings)

Translated using Weblate (Italian)

Currently translated at 76.0% (361 of 475 strings)

Co-authored-by: Edoardo Sorrenti <ed.sorrenti@gmail.com>
Co-authored-by: Filippo-riccardo Franzin (filippo franzin) <filric01@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-live/it/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/it/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/views-live
Translation: Frigate NVR/views-settings
2026-06-11 10:32:09 +02:00
Hosted Weblate
d608dd6de5
Translated using Weblate (Polish)
Currently translated at 31.3% (149 of 475 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (101 of 101 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (239 of 239 strings)

Translated using Weblate (Polish)

Currently translated at 98.4% (127 of 129 strings)

Translated using Weblate (Polish)

Currently translated at 9.1% (74 of 809 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Paweł Kapeluszny <cyberitsec@proton.me>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-dialog/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/pl/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-classificationmodel/pl/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/common
Translation: Frigate NVR/components-dialog
Translation: Frigate NVR/views-classificationmodel
2026-06-11 10:32:09 +02:00
Hosted Weblate
4c2c18f9b6
Translated using Weblate (Catalan)
Currently translated at 100.0% (809 of 809 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (Catalan)

Currently translated at 100.0% (1277 of 1277 strings)

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ca/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ca/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/views-settings
2026-06-11 10:32:09 +02:00
Hosted Weblate
039ff102bc
Translated using Weblate (Romanian)
Currently translated at 100.0% (809 of 809 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (Romanian)

Currently translated at 100.0% (1277 of 1277 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lukasig <lukasig@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/ro/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/ro/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/views-settings
2026-06-11 10:32:08 +02:00
Hosted Weblate
0055925d80
Translated using Weblate (Greek)
Currently translated at 14.3% (72 of 501 strings)

Translated using Weblate (Greek)

Currently translated at 49.7% (119 of 239 strings)

Co-authored-by: George Rovolis <georgios@rovolis.co.uk>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/audio/el/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/el/
Translation: Frigate NVR/audio
Translation: Frigate NVR/common
2026-06-11 10:32:08 +02:00
Hosted Weblate
3af6bd8667
Translated using Weblate (German)
Currently translated at 100.0% (475 of 475 strings)

Translated using Weblate (German)

Currently translated at 100.0% (50 of 50 strings)

Translated using Weblate (German)

Currently translated at 100.0% (809 of 809 strings)

Translated using Weblate (German)

Currently translated at 100.0% (62 of 62 strings)

Translated using Weblate (German)

Currently translated at 100.0% (1276 of 1276 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sebastian Sie <sebastian.neuplanitz@googlemail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/components-camera/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-motionsearch/de/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/de/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/components-camera
Translation: Frigate NVR/views-motionSearch
Translation: Frigate NVR/views-settings
2026-06-11 10:32:08 +02:00
17 changed files with 52 additions and 809 deletions

View File

@ -5,40 +5,20 @@ title: Glossary
The glossary explains terms commonly used in Frigate's documentation.
## Alert
The higher-priority of the two [review item](#review-item) severities, the other being a [detection](#detection). By default a review item is an alert when it involves a `person` or `car`; the qualifying [labels](#label) and [zones](#zone) can be configured. [See the review docs for more info](/configuration/review)
## Attribute
A property detected on an [object](#object) that exists alongside its [label](#label). Unlike a [sub label](#sub-label), an object can carry several attributes at once. Some attributes come directly from the object detection [model](#model) — for example `face`, `license_plate`, or delivery carrier logos such as `amazon`, `ups`, and `fedex` — while others come from a [custom object classification model](/configuration/custom_classification/object_classification) configured with the `attribute` type. Attributes are visible in the Tracked Object Details pane in Explore, in `frigate/events` MQTT messages, and through the HTTP API.
## Bounding Box
A box returned by the object detection [model](#model) that outlines a detected [object](#object) in the frame. In the Debug view, bounding boxes are colored by object [label](#label).
A box returned from the object detection model that outlines an object in the frame. These have multiple colors depending on object type in the debug live view.
### Bounding Box Colors
- At startup different colors will be assigned to each object label
- A dark blue thin line indicates that object is not detected at this current point in time
- A gray thin line indicates that object is detected as being stationary
- A thick line indicates that object is the subject of autotracking (when enabled)
## Class
The categories a classification [model](#model) is trained to distinguish between. Each class is a distinct visual category the model predicts, plus a `none` class for inputs that don't fit any category. For example, a custom object classification model for `person` objects might use the classes `delivery_person`, `resident`, and `none`. The predicted class is applied to the [object](#object) as either a [sub label](#sub-label) or an [attribute](#attribute), depending on the model's configuration. [See the object classification docs for more info](/configuration/custom_classification/object_classification)
## Detection
The lower-priority of the two [review item](#review-item) severities, the other being an [alert](#alert). By default, any review item that does not qualify as an alert is a detection; the qualifying [labels](#label) and [zones](#zone) can be configured. Despite the name, a detection is a category of review item — not the same as the object detection performed by the [model](#model). [See the review docs for more info](/configuration/review)
- A thick line indicates that object is the subject of autotracking (when enabled).
## False Positive
An incorrect result from the object detection [model](#model), where it assigns the wrong [label](#label) to something in the frame — for example a dog identified as a person, or a chair identified as a dog. A person correctly identified in an area you want to ignore is not a false positive.
## Label
The type assigned to a detected [object](#object) by the object detection [model](#model), drawn from the model's labelmap — for example `person`, `car`, or `dog`. Frigate tracks `person` by default; additional labels are tracked by adding them to the objects configuration. [See the available objects docs for the full list](/configuration/objects)
An incorrect detection of an object type. For example a dog being detected as a person, a chair being detected as a dog, etc. A person being detected in an area you want to ignore is not a false positive.
## Mask
@ -46,56 +26,44 @@ There are two types of masks in Frigate. [See the mask docs for more info](/conf
### Motion Mask
A motion mask stops [motion](#motion) in the masked area from triggering object detection. It does not stop an object from being detected when object detection runs because of motion in a nearby area. Use motion masks for parts of the frame that change constantly but never contain objects you care about — camera timestamps, the sky, the tops of trees, and so on.
Motion masks prevent detection of [motion](#motion) in masked areas from triggering Frigate to run object detection, but do not prevent objects from being detected if object detection runs due to motion in nearby areas. For example: camera timestamps, skies, the tops of trees, etc.
### Object Mask
An object filter mask drops any [bounding box](#bounding-box) whose bottom center falls inside the masked area (overlap elsewhere doesn't matter). The object is forced to be treated as a [false positive](#false-positive) and ignored.
Object filter masks drop any bounding boxes where the bottom center (overlap doesn't matter) is in the masked area. It forces them to be considered a [false positive](#false-positive) so that they are ignored.
## Min Score
The lowest score a detected object can have to be kept during tracking. Anything scoring below the minimum is assumed to be a [false positive](#false-positive) and discarded.
## Model
A machine learning model that Frigate uses to detect or classify objects. The object detection model locates [objects](#object) in each frame and returns their [labels](#label) and [bounding boxes](#bounding-box). Additional enrichment models run on tracked objects to add detail: face recognition, license plate recognition, bird classification, custom object and state classification, and the embedding models used for semantic search. [See the object detectors docs for more info](/configuration/object_detectors)
The lowest score that an object can be detected with during tracking, any detection with a lower score will be assumed to be a false positive
## Motion
A change in pixels between the current camera frame and previous frames. When many nearby pixels change together, they are grouped and shown as a red motion box in the debug live view. [See the motion detection docs for more info](/configuration/motion_detection)
## Object
Something Frigate can detect and follow in a camera frame, identified by its [label](#label) (for example a person or a car). The object types Frigate watches for are set in the `objects` configuration. Once an object is detected and followed across frames it becomes a [tracked object](#tracked-object-event-in-previous-versions), which may also carry a [sub label](#sub-label) and [attributes](#attribute). [See the available objects docs for more info](/configuration/objects)
When pixels in the current camera frame are different than previous frames. When many nearby pixels are different in the current frame they grouped together and indicated with a red motion box in the live debug view. [See the motion detection docs for more info](/configuration/motion_detection)
## Region
A portion of the camera frame sent to the object detection [model](#model). Regions are selected because of [motion](#motion), active objects, or occasionally to recheck stationary objects, and are shown as green boxes in the debug live view.
A portion of the camera frame that is sent to object detection, regions can be sent due to motion, active objects, or occasionally for stationary objects. These are represented by green boxes in the debug live view.
## Review Item
A period of time during which one or more [tracked objects](#tracked-object-event-in-previous-versions) were active, grouped together for review. Each review item is categorized as either an [alert](#alert) or a [detection](#detection). [See the review docs for more info](/configuration/review)
A review item is a time period where any number of events/tracked objects were active. [See the review docs for more info](/configuration/review)
## Snapshot Score
The object's score at the specific moment the snapshot was captured.
## Sub Label
A more specific identity assigned to a [tracked object](#tracked-object-event-in-previous-versions) in addition to its [label](#label). A `person` may get the name of a recognized face, a `car` may get the name of a known license plate, and a `bird` may get its species. An object can have only one sub label at a time. Sub labels are produced by face recognition, license plate recognition, bird classification, custom object classification configured with the `sub label` type, and semantic search triggers.
The score shown in a snapshot is the score of that object at that specific moment in time.
## Threshold
The median score an object must reach to be considered a true positive.
The threshold is the median score that an object must reach in order to be considered a true positive.
## Top Score
The highest median score an object reached over its lifetime.
The top score for an object is the highest median score for an object.
## Tracked Object ("event" in previous versions)
An [object](#object) followed from the moment it enters the frame until it leaves, including any time it stays still. A tracked object is saved once it is considered a [true positive](#threshold) and meets the requirements for a snapshot or recording.
The time period starting when a tracked object entered the frame and ending when it left the frame, including any time that the object remained still. Tracked objects are saved when it is considered a [true positive](#threshold) and meets the requirements for a snapshot or recording to be saved.
## Zone
A user-defined area of interest within the camera frame. Zones can be used for notifications and to limit where Frigate creates a [review item](#review-item). [See the zone docs for more info](/configuration/zones)
Zones are areas of interest, zones can be used for notifications and for limiting the areas where Frigate will create a [review item](#review-item). [See the zone docs for more info](/configuration/zones)

View File

@ -121,12 +121,6 @@ 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:

View File

@ -400,35 +400,6 @@ 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:

View File

@ -34,15 +34,11 @@ 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, get_record_segment_time
from frigate.util.builtin import clean_camera_user_pass
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 (
analyze_record_keyframes,
ffprobe_stream,
is_restricted_go2rtc_source,
)
from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source
logger = logging.getLogger(__name__)
@ -366,48 +362,6 @@ 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."""

View File

@ -73,12 +73,7 @@ class CameraConfigUpdateSubscriber:
base_topic = "config/cameras"
# global subscribers must hear every camera; only narrow per-camera workers
is_global_subscriber = (
CameraConfigUpdateEnum.add in self.topics
or CameraConfigUpdateEnum.remove in self.topics
)
if not is_global_subscriber and len(self.camera_configs) == 1:
if len(self.camera_configs) == 1:
base_topic += f"/{list(self.camera_configs.keys())[0]}"
self.subscriber = ConfigSubscriber(

View File

@ -1,58 +0,0 @@
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"

View File

@ -1,111 +0,0 @@
"""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()

View File

@ -14,16 +14,13 @@ import urllib.parse
from collections.abc import Mapping
from multiprocessing.managers import ValueProxy
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union
from typing import 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__)
@ -135,24 +132,6 @@ 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
):

View File

@ -879,131 +879,6 @@ 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:

View File

@ -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_record_segment_time
from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
from frigate.util.image import (
FrameManager,
@ -34,6 +34,23 @@ 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],
@ -168,7 +185,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)

View File

@ -174,21 +174,6 @@
"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",

View File

@ -55,10 +55,8 @@
},
"continuousStreaming": {
"desc": {
"title": "Kamera akan selalu live stream selama muncul di dashboard, meskipun tidak ada aktivitas yang terdeteksi.",
"warning": "Streaming Terus Menerus dapat menyebabkan penggunaan bandwidth yang tinggi dan masalah performa. Gunakan dengan hati-hati."
},
"label": "Streaming Terus Menerus"
"title": "Kamera akan selalu live stream selama muncul di dashboard, meskipun tidak ada aktivitas yang terdeteksi."
}
}
}
},
@ -84,7 +82,6 @@
"motion": "Gerakan",
"regions": "Wilayah",
"timestamp": "Timestamp",
"mask": "Masker",
"paths": "Path"
"mask": "Masker"
}
}

View File

@ -52,10 +52,7 @@
"export": "Ekspor",
"selectOrExport": "Pilih atau Ekspor",
"toast": {
"success": "Berhasil memulai ekspor. Lihat file pada halaman ekspor.",
"queued": "Ekspor diantrekan. Lihat progres di halaman ekspor.",
"view": "Melihat",
"batchSuccess_other": "{{count}} Ekspor dimulai. Membuka kasusnya sekarang."
"success": "Berhasil memulai ekspor. Lihat file pada halaman ekspor."
},
"case": {
"newCaseOption": "Membuat Kasus Baru",
@ -77,24 +74,14 @@
"noCameras": "Tidak ada Kamera Tersedia",
"detectionCount_other": "{{count}} Objek terlacak",
"nameLabel": "Nama Ekspor",
"exportButton_other": "Ekspor {{count}} kamera",
"cameraSelectionHelp": "Kamera dengan objek yang dilacak dalam rentang waktu ini telah dipilih sebelumnya",
"queueingButton": "Mengantrekan ekspor..."
"exportButton_other": "Ekspor {{count}} kamera"
},
"multi": {
"title_other": "Ekspor {{count}} Ulasan",
"title_other": "",
"toast": {
"startedNoCase_other": "{{count}} Ekspor Dimulai.",
"started_other": "{{count}} ekspor dimulai. Membuka kasusnya sekarang.",
"partial": "Memulai {{successful}} dari {{total}} ekspor. Gagal: {{failedItems}}",
"failed": "Gagal memulai {{total}} ekspor. Gagal: {{failedItems}}"
},
"description": "Ekspor setiap ulasan yang dipilih. Semua Ekspor akan dikelompokkan dalam satu kasus.",
"descriptionNoCase": "Ekspor setiap ulasan yang dipilih.",
"exportButton_other": "Ekspor {{count}} ulasan",
"exportingButton": "Mengekspor..."
},
"queueing": "Mengantrekan Ekspor..."
"startedNoCase_other": "{{count}} Ekspor Dimulai."
}
}
},
"search": {
"saveSearch": {

View File

@ -179,17 +179,11 @@
"label": "Numero di processi di rilevamento",
"description": "Il numero di processi utilizzati per l'inferenza basata sulla CPU."
},
"description": "Rilevatore CPU TFLite che esegue modelli TensorFlow Lite sulla CPU di sistema senza accelerazione hardware. Sconsigliato.",
"label": "CPU"
"description": "Rilevatore CPU TFLite che esegue modelli TensorFlow Lite sulla CPU di sistema senza accelerazione hardware. Sconsigliato."
},
"label": "Dispositivo di rilevamento",
"hailo8l": {
"description": "Rilevatore Hailo-8/Hailo-8L che utilizza modelli HEF e l'SDK HailoRT per l'inferenza sul dispositivo Hailo.",
"label": "Hailo-8/Hailo-8L",
"device": {
"label": "Tipo di dispositivo",
"description": "Il dispositivo da utilizzare per l'inferenza Hailo (ad es. 'PCIe', 'M.2')."
}
"description": "Rilevatore Hailo-8/Hailo-8L che utilizza modelli HEF e l'SDK HailoRT per l'inferenza sul dispositivo Hailo."
},
"openvino": {
"description": "Rilevatore OpenVINO per CPU AMD e Intel, GPU Intel e dispositivo Intel VPU."
@ -224,88 +218,7 @@
"description": "Altezza del tensore di input del modello in pixel."
},
"labelmap": {
"label": "Personalizzazione labelmap",
"description": "Sostituzioni o voci di rimappatura da unire alla labelmap standard."
},
"attributes_map": {
"label": "Mappa delle etichette oggetto con le relative etichette di attributo",
"description": "Mappatura dalle etichette oggetto alle etichette di attributo utilizzata per allegare metadati (ad esempio 'car' -> ['license_plate'])."
},
"input_tensor": {
"label": "Forma del tensore di input del modello",
"description": "Formato del tensore richiesto dal modello: 'nhwc' o 'nchw'."
},
"input_pixel_format": {
"label": "Formato colore dei pixel di input del modello",
"description": "Spazio colore dei pixel richiesto dal modello: 'rgb', 'bgr' o 'yuv'."
},
"input_dtype": {
"label": "Tipo di dati di input del modello (Dtype)",
"description": "Tipo di dati del tensore di input del modello (ad esempio 'float32')."
},
"model_type": {
"label": "Tipo di modello di rilevamento oggetti",
"description": "Tipo di architettura del modello del rilevatore (ssd, yolox, yolonas) utilizzato da alcuni rilevatori per l'ottimizzazione."
}
},
"model_path": {
"label": "Percorso del modello specifico del rilevatore",
"description": "Percorso del file binario del modello del rilevatore, se richiesto dal rilevatore scelto."
},
"axengine": {
"label": "NPU AXEngine",
"description": "Rilevatore NPU AXERA AX650N/AX8850N che esegue file .axmodel compilati tramite il runtime AXEngine."
},
"deepstack": {
"label": "DeepStack",
"description": "Rilevatore DeepStack/CodeProject.AI che invia immagini a una API HTTP DeepStack remota per l'inferenza. Non consigliato.",
"api_url": {
"label": "URL API DeepStack",
"description": "L'URL delle API DeepStack."
},
"api_timeout": {
"label": "Timeout API DeepStack (in secondi)",
"description": "Tempo massimo consentito per una richiesta alle API DeepStack."
},
"api_key": {
"label": "Chiave API DeepStack (se richiesta)",
"description": "Chiave API opzionale per i servizi DeepStack autenticati."
}
},
"degirum": {
"label": "DeGirum",
"description": "Rilevatore DeGirum per l'esecuzione di modelli tramite il cloud DeGirum o servizi di inferenza locali.",
"zoo": {
"label": "Model Zoo",
"description": "Percorso o URL del model zoo di DeGirum."
},
"token": {
"label": "Token Cloud DeGirum",
"description": "Token per l'accesso al Cloud DeGirum."
}
},
"edgetpu": {
"label": "EdgeTPU",
"description": "Rilevatore EdgeTPU che esegue modelli TensorFlow Lite compilati per Coral EdgeTPU utilizzando il delegate EdgeTPU.",
"device": {
"label": "Tipo di dispositivo",
"description": "Il dispositivo da utilizzare per l'inferenza EdgeTPU (ad es. 'usb', 'pci')."
}
},
"memryx": {
"label": "MemryX",
"description": "Rilevatore MemryX MX3 che esegue modelli DFP compilati su acceleratori MemryX.",
"device": {
"label": "Percorso del dispositivo",
"description": "Il dispositivo da utilizzare per l'inferenza MemryX (ad es. 'PCIe')."
}
},
"onnx": {
"label": "ONNX",
"description": "Rilevatore ONNX per l'esecuzione di modelli ONNX; utilizzerà i backend di accelerazione disponibili (CUDA/ROCm/OpenVINO) quando presenti.",
"device": {
"label": "Tipo di dispositivo",
"description": "Il dispositivo da utilizzare per l'inferenza ONNX (ad es. 'AUTO', 'CPU', 'GPU')."
"label": "Personalizzazione labelmap"
}
}
},

View File

@ -7,8 +7,7 @@ import {
DialogTitle,
} from "../ui/dialog";
import ActivityIndicator from "../indicators/activity-indicator";
import KeyframeAnalysisSection from "./KeyframeAnalysisSection";
import { Ffprobe, KeyframeAnalysis } from "@/types/stats";
import { Ffprobe } from "@/types/stats";
import { Button } from "../ui/button";
import copy from "copy-to-clipboard";
import { CameraConfig } from "@/types/frigateConfig";
@ -31,7 +30,6 @@ export default function CameraInfoDialog({
}: CameraInfoDialogProps) {
const { t } = useTranslation(["views/system"]);
const [ffprobeInfo, setFfprobeInfo] = useState<Ffprobe[]>();
const [keyframeInfo, setKeyframeInfo] = useState<KeyframeAnalysis>();
useEffect(() => {
axios
@ -69,12 +67,7 @@ export default function CameraInfoDialog({
}, []);
const onCopyFfprobe = async () => {
copy(
JSON.stringify({
ffprobe: ffprobeInfo,
keyframe_analysis: keyframeInfo,
}),
);
copy(JSON.stringify(ffprobeInfo));
toast.success(t("cameras.toast.success.copyToClipboard"));
};
@ -103,7 +96,7 @@ export default function CameraInfoDialog({
<Trans ns="views/system">cameras.info.streamDataFromFFPROBE</Trans>
</DialogDescription>
<div className="mb-2 p-4 text-sm">
<div className="mb-2 p-4">
{ffprobeInfo ? (
<div>
{ffprobeInfo.map((stream, idx) => (
@ -191,10 +184,6 @@ export default function CameraInfoDialog({
)}
</div>
))}
<KeyframeAnalysisSection
cameraName={camera.name}
onResult={setKeyframeInfo}
/>
</div>
) : (
<div className="flex flex-col items-center">

View File

@ -1,193 +0,0 @@
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<KeyframeAnalysis>();
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 <Row icon="unknown">{t("cameras.info.keyframes.unknown")}</Row>;
}
if (!analysis) {
return (
<div className="flex items-center gap-2 text-muted-foreground">
<ActivityIndicator className="size-4" />
<span>
{secondsRemaining > 0
? t("cameras.info.keyframes.analyzing", {
seconds: secondsRemaining,
})
: t("cameras.info.keyframes.stillAnalyzing")}
</span>
</div>
);
}
let summary;
switch (analysis.severity) {
case "ok":
summary = (
<Row icon="ok">
{t("cameras.info.keyframes.ok", { seconds: analysis.mean_gap })}
</Row>
);
break;
case "warning":
summary = (
<Row icon="warning">
{t("cameras.info.keyframes.warning", { seconds: analysis.max_gap })}
</Row>
);
break;
case "error":
summary = (
<Row icon="error">
{t("cameras.info.keyframes.error", {
seconds: analysis.max_gap,
segmentTime: analysis.segment_time,
})}
</Row>
);
break;
case "record_disabled":
summary = (
<Row icon="unknown">{t("cameras.info.keyframes.recordDisabled")}</Row>
);
break;
default:
summary = (
<Row icon="unknown">{t("cameras.info.keyframes.unknown")}</Row>
);
}
// 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 (
<div className="text-muted-foreground">
{analysis.stream_index != null && (
<div>
{t("cameras.info.keyframes.recordStream")}{" "}
<span className="text-primary">
{t("cameras.info.stream", { idx: analysis.stream_index + 1 })}
</span>
</div>
)}
{hasStats && (
<div>
<div>
{t("cameras.info.keyframes.keyframeCount")}{" "}
<span className="text-primary">{analysis.keyframe_count}</span>
</div>
<div>
{t("cameras.info.keyframes.observedDuration")}{" "}
<span className="text-primary">
{analysis.duration_observed}s
</span>
</div>
<div>
{t("cameras.info.keyframes.gap")}{" "}
<span className="text-primary">
{analysis.min_gap}s / {analysis.mean_gap}s / {analysis.max_gap}s
</span>
</div>
<div>
{t("cameras.info.keyframes.segmentLength")}{" "}
<span className="text-primary">{analysis.segment_time}s</span>
</div>
</div>
)}
<div className={hasDetails ? "mt-3" : undefined}>{summary}</div>
</div>
);
}, [analysis, failed, secondsRemaining, t]);
return (
<div className="mb-5">
<div className="mb-1 rounded-md bg-secondary p-2 text-lg text-primary">
{t("cameras.info.keyframes.title")}
</div>
<div className="ml-2">{content}</div>
</div>
);
}
type RowProps = {
icon: "ok" | "warning" | "error" | "unknown";
children: React.ReactNode;
};
function Row({ icon, children }: RowProps) {
return (
<div className="flex items-start gap-2">
{icon === "ok" && (
<FaCircleCheck className="mt-0.5 size-4 flex-shrink-0 text-success" />
)}
{icon === "warning" && (
<FaTriangleExclamation className="mt-0.5 size-4 flex-shrink-0 text-yellow-500" />
)}
{icon === "error" && (
<LuX className="mt-0.5 size-4 flex-shrink-0 text-danger" />
)}
{icon === "unknown" && (
<FaTriangleExclamation className="mt-0.5 size-4 flex-shrink-0 text-muted-foreground" />
)}
<span className="text-primary">{children}</span>
</div>
);
}

View File

@ -135,22 +135,3 @@ 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 };
};