mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-01-22 20:18:30 +03:00
Compare commits
3 Commits
4e6c1f74e0
...
fe4dafd9bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4dafd9bf | ||
|
|
d633c7d966 | ||
|
|
7ec1d5d2c6 |
@ -79,6 +79,12 @@ cameras:
|
|||||||
|
|
||||||
If the ONVIF connection is successful, PTZ controls will be available in the camera's WebUI.
|
If the ONVIF connection is successful, PTZ controls will be available in the camera's WebUI.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
|
||||||
|
Some cameras use a separate ONVIF/service account that is distinct from the device administrator credentials. If ONVIF authentication fails with the admin account, try creating or using an ONVIF/service user in the camera's firmware. Refer to your camera manufacturer's documentation for more.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
|
|
||||||
If your ONVIF camera does not require authentication credentials, you may still need to specify an empty string for `user` and `password`, eg: `user: ""` and `password: ""`.
|
If your ONVIF camera does not require authentication credentials, you may still need to specify an empty string for `user` and `password`, eg: `user: ""` and `password: ""`.
|
||||||
@ -95,7 +101,7 @@ The FeatureList on the [ONVIF Conformant Products Database](https://www.onvif.or
|
|||||||
|
|
||||||
| Brand or specific camera | PTZ Controls | Autotracking | Notes |
|
| Brand or specific camera | PTZ Controls | Autotracking | Notes |
|
||||||
| ---------------------------- | :----------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | :----------: | :----------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Amcrest | ✅ | ✅ | ⛔️ Generally, Amcrest should work, but some older models (like the common IP2M-841) don't support autotracking |
|
| Amcrest | ✅ | ✅ | ⛔️ Generally, Amcrest should work, but some older models (like the common IP2M-841) don't support autotracking |
|
||||||
| Amcrest ASH21 | ✅ | ❌ | ONVIF service port: 80 |
|
| Amcrest ASH21 | ✅ | ❌ | ONVIF service port: 80 |
|
||||||
| Amcrest IP4M-S2112EW-AI | ✅ | ❌ | FOV relative movement not supported. |
|
| Amcrest IP4M-S2112EW-AI | ✅ | ❌ | FOV relative movement not supported. |
|
||||||
| Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. |
|
| Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. |
|
||||||
|
|||||||
@ -125,10 +125,10 @@ review:
|
|||||||
|
|
||||||
## Review Reports
|
## Review Reports
|
||||||
|
|
||||||
Along with individual review item summaries, Generative AI provides the ability to request a report of a given time period. For example, you can get a daily report while on a vacation of any suspicious activity or other concerns that may require review.
|
Along with individual review item summaries, Generative AI can also produce a single report of review items from all cameras marked "suspicious" over a specified time period (for example, a daily summary of suspicious activity while you're on vacation).
|
||||||
|
|
||||||
### Requesting Reports Programmatically
|
### Requesting Reports Programmatically
|
||||||
|
|
||||||
Review reports can be requested via the [API](/integrations/api#review-summarization) by sending a POST request to `/api/review/summarize/start/{start_ts}/end/{end_ts}` with Unix timestamps.
|
Review reports can be requested via the [API](/integrations/api/generate-review-summary-review-summarize-start-start-ts-end-end-ts-post) by sending a POST request to `/api/review/summarize/start/{start_ts}/end/{end_ts}` with Unix timestamps.
|
||||||
|
|
||||||
For Home Assistant users, there is a built-in service (`frigate.review_summarize`) that makes it easy to request review reports as part of automations or scripts. This allows you to automatically generate daily summaries, vacation reports, or custom time period reports based on your specific needs.
|
For Home Assistant users, there is a built-in service (`frigate.review_summarize`) that makes it easy to request review reports as part of automations or scripts. This allows you to automatically generate daily summaries, vacation reports, or custom time period reports based on your specific needs.
|
||||||
|
|||||||
@ -510,6 +510,12 @@ record:
|
|||||||
# Optional: Number of minutes to wait between cleanup runs (default: shown below)
|
# Optional: Number of minutes to wait between cleanup runs (default: shown below)
|
||||||
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
|
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
|
||||||
expire_interval: 60
|
expire_interval: 60
|
||||||
|
# Optional: Maximum size of recordings in MB or string format (e.g. 10GB). (default: shown below)
|
||||||
|
# This serves as a hard limit for the size of the recordings for this camera.
|
||||||
|
# If the total size of recordings exceeds this limit, the oldest recordings will be deleted
|
||||||
|
# until the total size is below the limit, regardless of retention settings.
|
||||||
|
# 0 means no limit.
|
||||||
|
max_size: 0
|
||||||
# Optional: Two-way sync recordings database with disk on startup and once a day (default: shown below).
|
# Optional: Two-way sync recordings database with disk on startup and once a day (default: shown below).
|
||||||
sync_recordings: False
|
sync_recordings: False
|
||||||
# Optional: Continuous retention settings
|
# Optional: Continuous retention settings
|
||||||
|
|||||||
@ -11,6 +11,12 @@ Cameras configured to output H.264 video and AAC audio will offer the most compa
|
|||||||
|
|
||||||
- **Stream Viewing**: This stream will be rebroadcast as is to Home Assistant for viewing with the stream component. Setting this resolution too high will use significant bandwidth when viewing streams in Home Assistant, and they may not load reliably over slower connections.
|
- **Stream Viewing**: This stream will be rebroadcast as is to Home Assistant for viewing with the stream component. Setting this resolution too high will use significant bandwidth when viewing streams in Home Assistant, and they may not load reliably over slower connections.
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
For the best experience in Frigate's UI, configure your camera so that the detection and recording streams use the same aspect ratio. For example, if your main stream is 3840x2160 (16:9), set your substream to 640x360 (also 16:9) instead of 640x480 (4:3). While not strictly required, matching aspect ratios helps ensure seamless live stream display and preview/recordings playback.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
### Choosing a detect resolution
|
### Choosing a detect resolution
|
||||||
|
|
||||||
The ideal resolution for detection is one where the objects you want to detect fit inside the dimensions of the model used by Frigate (320x320). Frigate does not pass the entire camera frame to object detection. It will crop an area of motion from the full frame and look in that portion of the frame. If the area being inspected is larger than 320x320, Frigate must resize it before running object detection. Higher resolutions do not improve the detection accuracy because the additional detail is lost in the resize. Below you can see a reference for how large a 320x320 area is against common resolutions.
|
The ideal resolution for detection is one where the objects you want to detect fit inside the dimensions of the model used by Frigate (320x320). Frigate does not pass the entire camera frame to object detection. It will crop an area of motion from the full frame and look in that portion of the frame. If the area being inspected is larger than 320x320, Frigate must resize it before running object detection. Higher resolutions do not improve the detection accuracy because the additional detail is lost in the resize. Below you can see a reference for how large a 320x320 area is against common resolutions.
|
||||||
|
|||||||
@ -42,7 +42,7 @@ If the EQ13 is out of stock, the link below may take you to a suggested alternat
|
|||||||
| ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------- |
|
| ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||||
| Beelink EQ13 (<a href="https://amzn.to/4jn2qVr" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | Can run object detection on several 1080p cameras with low-medium activity | Dual gigabit NICs for easy isolated camera network. |
|
| Beelink EQ13 (<a href="https://amzn.to/4jn2qVr" target="_blank" rel="nofollow noopener sponsored">Amazon</a>) | Can run object detection on several 1080p cameras with low-medium activity | Dual gigabit NICs for easy isolated camera network. |
|
||||||
| Intel 1120p ([Amazon](https://www.amazon.com/Beelink-i3-1220P-Computer-Display-Gigabit/dp/B0DDCKT9YP) | Can handle a large number of 1080p cameras with high activity | |
|
| Intel 1120p ([Amazon](https://www.amazon.com/Beelink-i3-1220P-Computer-Display-Gigabit/dp/B0DDCKT9YP) | Can handle a large number of 1080p cameras with high activity | |
|
||||||
| Intel 125H ([Amazon](https://www.amazon.com/MINISFORUM-Pro-125H-Barebone-Computer-HDMI2-1/dp/B0FH21FSZM) | Can handle a significant number of 1080p cameras with high activity | Includes NPU for more efficient detection in 0.17+ |
|
| Intel 125H ([Amazon](https://www.amazon.com/MINISFORUM-Pro-125H-Barebone-Computer-HDMI2-1/dp/B0FH21FSZM) | Can handle a significant number of 1080p cameras with high activity | Includes NPU for more efficient detection in 0.17+ |
|
||||||
|
|
||||||
## Detectors
|
## Detectors
|
||||||
|
|
||||||
@ -55,12 +55,10 @@ Frigate supports multiple different detectors that work on different types of ha
|
|||||||
**Most Hardware**
|
**Most Hardware**
|
||||||
|
|
||||||
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices offering a wide range of compatibility with devices.
|
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices offering a wide range of compatibility with devices.
|
||||||
|
|
||||||
- [Supports many model architectures](../../configuration/object_detectors#configuration)
|
- [Supports many model architectures](../../configuration/object_detectors#configuration)
|
||||||
- Runs best with tiny or small size models
|
- Runs best with tiny or small size models
|
||||||
|
|
||||||
- [Google Coral EdgeTPU](#google-coral-tpu): The Google Coral EdgeTPU is available in USB and m.2 format allowing for a wide range of compatibility with devices.
|
- [Google Coral EdgeTPU](#google-coral-tpu): The Google Coral EdgeTPU is available in USB and m.2 format allowing for a wide range of compatibility with devices.
|
||||||
|
|
||||||
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#edge-tpu-detector)
|
- [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#edge-tpu-detector)
|
||||||
|
|
||||||
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 M.2 accelerator module is available in m.2 format allowing for a wide range of compatibility with devices.
|
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 M.2 accelerator module is available in m.2 format allowing for a wide range of compatibility with devices.
|
||||||
@ -89,7 +87,6 @@ Frigate supports multiple different detectors that work on different types of ha
|
|||||||
**Nvidia**
|
**Nvidia**
|
||||||
|
|
||||||
- [TensortRT](#tensorrt---nvidia-gpu): TensorRT can run on Nvidia GPUs to provide efficient object detection.
|
- [TensortRT](#tensorrt---nvidia-gpu): TensorRT can run on Nvidia GPUs to provide efficient object detection.
|
||||||
|
|
||||||
- [Supports majority of model architectures via ONNX](../../configuration/object_detectors#onnx-supported-models)
|
- [Supports majority of model architectures via ONNX](../../configuration/object_detectors#onnx-supported-models)
|
||||||
- Runs well with any size models including large
|
- Runs well with any size models including large
|
||||||
|
|
||||||
@ -152,9 +149,7 @@ The OpenVINO detector type is able to run on:
|
|||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
Intel NPUs have seen [limited success in community deployments](https://github.com/blakeblackshear/frigate/discussions/13248#discussioncomment-12347357), although they remain officially unsupported.
|
Intel B-series (Battlemage) GPUs are not officially supported with Frigate 0.17, though a user has [provided steps to rebuild the Frigate container](https://github.com/blakeblackshear/frigate/discussions/21257) with support for them.
|
||||||
|
|
||||||
In testing, the NPU delivered performance that was only comparable to — or in some cases worse than — the integrated GPU.
|
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ cameras:
|
|||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. Export or copy the clip you want to replay to the Frigate host (e.g., `/media/frigate/` or `debug/clips/`).
|
1. Export or copy the clip you want to replay to the Frigate host (e.g., `/media/frigate/` or `debug/clips/`). Depending on what you are looking to debug, it is often helpful to add some "pre-capture" time (where the tracked object is not yet visible) to the clip when exporting.
|
||||||
2. Add the temporary camera to `config/config.yml` (example above). Use a unique name such as `test` or `replay_camera` so it's easy to remove later.
|
2. Add the temporary camera to `config/config.yml` (example above). Use a unique name such as `test` or `replay_camera` so it's easy to remove later.
|
||||||
- If you're debugging a specific camera, copy the settings from that camera (frame rate, model/enrichment settings, zones, etc.) into the temporary camera so the replay closely matches the original environment. Leave `record` and `snapshots` disabled unless you are specifically debugging recording or snapshot behavior.
|
- If you're debugging a specific camera, copy the settings from that camera (frame rate, model/enrichment settings, zones, etc.) into the temporary camera so the replay closely matches the original environment. Leave `record` and `snapshots` disabled unless you are specifically debugging recording or snapshot behavior.
|
||||||
3. Restart Frigate.
|
3. Restart Frigate.
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional, Union
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field, field_validator
|
||||||
|
|
||||||
from frigate.const import MAX_PRE_CAPTURE
|
from frigate.const import MAX_PRE_CAPTURE
|
||||||
from frigate.review.types import SeverityEnum
|
from frigate.review.types import SeverityEnum
|
||||||
|
from frigate.util.size import parse_size_to_mb
|
||||||
|
|
||||||
from ..base import FrigateBaseModel
|
from ..base import FrigateBaseModel
|
||||||
|
|
||||||
@ -81,6 +82,10 @@ class RecordConfig(FrigateBaseModel):
|
|||||||
default=60,
|
default=60,
|
||||||
title="Number of minutes to wait between cleanup runs.",
|
title="Number of minutes to wait between cleanup runs.",
|
||||||
)
|
)
|
||||||
|
max_size: Union[float, str] = Field(
|
||||||
|
default=0,
|
||||||
|
title="Maximum size of recordings in MB or string format (e.g. 10GB).",
|
||||||
|
)
|
||||||
continuous: RecordRetainConfig = Field(
|
continuous: RecordRetainConfig = Field(
|
||||||
default_factory=RecordRetainConfig,
|
default_factory=RecordRetainConfig,
|
||||||
title="Continuous recording retention settings.",
|
title="Continuous recording retention settings.",
|
||||||
@ -104,6 +109,16 @@ class RecordConfig(FrigateBaseModel):
|
|||||||
default=None, title="Keep track of original state of recording."
|
default=None, title="Keep track of original state of recording."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@field_validator("max_size", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_max_size(cls, v: Union[float, str], info: object) -> float:
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
return parse_size_to_mb(v)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid size string: {v}")
|
||||||
|
return v
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def event_pre_capture(self) -> int:
|
def event_pre_capture(self) -> int:
|
||||||
return max(
|
return max(
|
||||||
|
|||||||
@ -20,6 +20,17 @@ from frigate.util.time import get_tomorrow_at_time
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_directory_size(directory: str) -> float:
|
||||||
|
"""Get the size of a directory in MB."""
|
||||||
|
total_size = 0
|
||||||
|
for dirpath, dirnames, filenames in os.walk(directory):
|
||||||
|
for f in filenames:
|
||||||
|
fp = os.path.join(dirpath, f)
|
||||||
|
if not os.path.islink(fp):
|
||||||
|
total_size += os.path.getsize(fp)
|
||||||
|
return total_size / 1000000
|
||||||
|
|
||||||
|
|
||||||
class RecordingCleanup(threading.Thread):
|
class RecordingCleanup(threading.Thread):
|
||||||
"""Cleanup existing recordings based on retention config."""
|
"""Cleanup existing recordings based on retention config."""
|
||||||
|
|
||||||
@ -120,6 +131,7 @@ class RecordingCleanup(threading.Thread):
|
|||||||
Recordings.objects,
|
Recordings.objects,
|
||||||
Recordings.motion,
|
Recordings.motion,
|
||||||
Recordings.dBFS,
|
Recordings.dBFS,
|
||||||
|
Recordings.segment_size,
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
(Recordings.camera == config.name)
|
(Recordings.camera == config.name)
|
||||||
@ -206,6 +218,10 @@ class RecordingCleanup(threading.Thread):
|
|||||||
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
Recordings.id << deleted_recordings_list[i : i + max_deletes]
|
||||||
).execute()
|
).execute()
|
||||||
|
|
||||||
|
# Check if we need to enforce max_size
|
||||||
|
if config.record.max_size > 0:
|
||||||
|
self.enforce_max_size(config, deleted_recordings)
|
||||||
|
|
||||||
previews: list[Previews] = (
|
previews: list[Previews] = (
|
||||||
Previews.select(
|
Previews.select(
|
||||||
Previews.id,
|
Previews.id,
|
||||||
@ -266,6 +282,52 @@ class RecordingCleanup(threading.Thread):
|
|||||||
Previews.id << deleted_previews_list[i : i + max_deletes]
|
Previews.id << deleted_previews_list[i : i + max_deletes]
|
||||||
).execute()
|
).execute()
|
||||||
|
|
||||||
|
def enforce_max_size(
|
||||||
|
self, config: CameraConfig, deleted_recordings: set[str]
|
||||||
|
) -> None:
|
||||||
|
"""Ensure that the camera recordings do not exceed the max size."""
|
||||||
|
# Get all recordings for this camera
|
||||||
|
recordings: Recordings = (
|
||||||
|
Recordings.select(
|
||||||
|
Recordings.id,
|
||||||
|
Recordings.path,
|
||||||
|
Recordings.segment_size,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(Recordings.camera == config.name)
|
||||||
|
& (Recordings.id.not_in(list(deleted_recordings)))
|
||||||
|
)
|
||||||
|
.order_by(Recordings.start_time)
|
||||||
|
.namedtuples()
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
|
||||||
|
total_size = 0
|
||||||
|
recordings_list = []
|
||||||
|
for recording in recordings:
|
||||||
|
recordings_list.append(recording)
|
||||||
|
total_size += recording.segment_size
|
||||||
|
|
||||||
|
# If the total size is less than the max size, we are good
|
||||||
|
if total_size <= config.record.max_size:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Delete recordings until we are under the max size
|
||||||
|
recordings_to_delete = []
|
||||||
|
for recording in recordings_list:
|
||||||
|
total_size -= recording.segment_size
|
||||||
|
recordings_to_delete.append(recording.id)
|
||||||
|
Path(recording.path).unlink(missing_ok=True)
|
||||||
|
if total_size <= config.record.max_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Delete from database
|
||||||
|
max_deletes = 100000
|
||||||
|
for i in range(0, len(recordings_to_delete), max_deletes):
|
||||||
|
Recordings.delete().where(
|
||||||
|
Recordings.id << recordings_to_delete[i : i + max_deletes]
|
||||||
|
).execute()
|
||||||
|
|
||||||
def expire_recordings(self) -> None:
|
def expire_recordings(self) -> None:
|
||||||
"""Delete recordings based on retention config."""
|
"""Delete recordings based on retention config."""
|
||||||
logger.debug("Start expire recordings.")
|
logger.debug("Start expire recordings.")
|
||||||
|
|||||||
@ -429,6 +429,29 @@ class TestConfig(unittest.TestCase):
|
|||||||
frigate_config = FrigateConfig(**config)
|
frigate_config = FrigateConfig(**config)
|
||||||
assert "-rtsp_transport" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
assert "-rtsp_transport" in frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]
|
||||||
|
|
||||||
|
def test_record_max_size_validation(self):
|
||||||
|
config = {
|
||||||
|
"mqtt": {"host": "mqtt"},
|
||||||
|
"record": {"max_size": "10GB"},
|
||||||
|
"cameras": {
|
||||||
|
"back": {
|
||||||
|
"ffmpeg": {
|
||||||
|
"inputs": [
|
||||||
|
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"height": 1080,
|
||||||
|
"width": 1920,
|
||||||
|
"fps": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
frigate_config = FrigateConfig(**config)
|
||||||
|
assert frigate_config.record.max_size == 10000
|
||||||
|
|
||||||
def test_ffmpeg_params_global(self):
|
def test_ffmpeg_params_global(self):
|
||||||
config = {
|
config = {
|
||||||
"ffmpeg": {"input_args": "-re"},
|
"ffmpeg": {"input_args": "-re"},
|
||||||
|
|||||||
@ -31,3 +31,15 @@ class TestRecordRetention(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
assert not segment_info.should_discard_segment(RetainModeEnum.motion)
|
assert not segment_info.should_discard_segment(RetainModeEnum.motion)
|
||||||
assert segment_info.should_discard_segment(RetainModeEnum.active_objects)
|
assert segment_info.should_discard_segment(RetainModeEnum.active_objects)
|
||||||
|
|
||||||
|
def test_size_utility(self):
|
||||||
|
from frigate.util.size import parse_size_to_mb
|
||||||
|
|
||||||
|
assert parse_size_to_mb("10GB") == 10240
|
||||||
|
assert parse_size_to_mb("10MB") == 10
|
||||||
|
assert parse_size_to_mb("1024KB") == 1
|
||||||
|
assert parse_size_to_mb("1048576B") == 1
|
||||||
|
assert parse_size_to_mb("10") == 10
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
parse_size_to_mb("invalid")
|
||||||
|
|||||||
21
frigate/util/size.py
Normal file
21
frigate/util/size.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"""Utility for parsing size strings."""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size_to_mb(size_str: str) -> float:
|
||||||
|
"""Parse a size string to megabytes."""
|
||||||
|
size_str = size_str.strip().upper()
|
||||||
|
if size_str.endswith("TB"):
|
||||||
|
return float(size_str[:-2]) * 1024 * 1024
|
||||||
|
elif size_str.endswith("GB"):
|
||||||
|
return float(size_str[:-2]) * 1024
|
||||||
|
elif size_str.endswith("MB"):
|
||||||
|
return float(size_str[:-2])
|
||||||
|
elif size_str.endswith("KB"):
|
||||||
|
return float(size_str[:-2]) / 1024
|
||||||
|
elif size_str.endswith("B"):
|
||||||
|
return float(size_str[:-1]) / (1024 * 1024)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return float(size_str)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid size string: {size_str}")
|
||||||
@ -64,10 +64,12 @@ def stop_ffmpeg(ffmpeg_process: sp.Popen[Any], logger: logging.Logger):
|
|||||||
try:
|
try:
|
||||||
logger.info("Waiting for ffmpeg to exit gracefully...")
|
logger.info("Waiting for ffmpeg to exit gracefully...")
|
||||||
ffmpeg_process.communicate(timeout=30)
|
ffmpeg_process.communicate(timeout=30)
|
||||||
|
logger.info("FFmpeg has exited")
|
||||||
except sp.TimeoutExpired:
|
except sp.TimeoutExpired:
|
||||||
logger.info("FFmpeg didn't exit. Force killing...")
|
logger.info("FFmpeg didn't exit. Force killing...")
|
||||||
ffmpeg_process.kill()
|
ffmpeg_process.kill()
|
||||||
ffmpeg_process.communicate()
|
ffmpeg_process.communicate()
|
||||||
|
logger.info("FFmpeg has been killed")
|
||||||
ffmpeg_process = None
|
ffmpeg_process = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -181,6 +181,16 @@
|
|||||||
"restricted": {
|
"restricted": {
|
||||||
"title": "No Cameras Available",
|
"title": "No Cameras Available",
|
||||||
"description": "You don't have permission to view any cameras in this group."
|
"description": "You don't have permission to view any cameras in this group."
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"title": "No Cameras Configured",
|
||||||
|
"description": "Get started by connecting a camera to Frigate.",
|
||||||
|
"buttonText": "Add Camera"
|
||||||
|
},
|
||||||
|
"group": {
|
||||||
|
"title": "No Cameras in Group",
|
||||||
|
"description": "This camera group has no assigned or enabled cameras.",
|
||||||
|
"buttonText": "Manage Groups"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -386,11 +386,11 @@
|
|||||||
"title": "Camera Review Settings",
|
"title": "Camera Review Settings",
|
||||||
"object_descriptions": {
|
"object_descriptions": {
|
||||||
"title": "Generative AI Object Descriptions",
|
"title": "Generative AI Object Descriptions",
|
||||||
"desc": "Temporarily enable/disable Generative AI object descriptions for this camera. When disabled, AI generated descriptions will not be requested for tracked objects on this camera."
|
"desc": "Temporarily enable/disable Generative AI object descriptions for this camera until Frigate restarts. When disabled, AI generated descriptions will not be requested for tracked objects on this camera."
|
||||||
},
|
},
|
||||||
"review_descriptions": {
|
"review_descriptions": {
|
||||||
"title": "Generative AI Review Descriptions",
|
"title": "Generative AI Review Descriptions",
|
||||||
"desc": "Temporarily enable/disable Generative AI review descriptions for this camera. When disabled, AI generated descriptions will not be requested for review items on this camera."
|
"desc": "Temporarily enable/disable Generative AI review descriptions for this camera until Frigate restarts. When disabled, AI generated descriptions will not be requested for review items on this camera."
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"title": "Review",
|
"title": "Review",
|
||||||
|
|||||||
@ -35,7 +35,9 @@ export function EmptyCard({
|
|||||||
{icon}
|
{icon}
|
||||||
{TitleComponent}
|
{TitleComponent}
|
||||||
{description && (
|
{description && (
|
||||||
<div className="mb-3 text-secondary-foreground">{description}</div>
|
<div className="mb-3 text-center text-secondary-foreground">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{buttonText?.length && (
|
{buttonText?.length && (
|
||||||
<Button size="sm" variant="select">
|
<Button size="sm" variant="select">
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { REVIEW_PADDING } from "@/types/review";
|
import { REVIEW_PADDING } from "@/types/review";
|
||||||
import {
|
import {
|
||||||
ASPECT_VERTICAL_LAYOUT,
|
ASPECT_PORTRAIT_LAYOUT,
|
||||||
ASPECT_WIDE_LAYOUT,
|
ASPECT_WIDE_LAYOUT,
|
||||||
Recording,
|
Recording,
|
||||||
} from "@/types/record";
|
} from "@/types/record";
|
||||||
@ -39,6 +39,7 @@ import { useApiHost } from "@/api";
|
|||||||
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
||||||
import ObjectTrackOverlay from "../ObjectTrackOverlay";
|
import ObjectTrackOverlay from "../ObjectTrackOverlay";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
import { VideoResolutionType } from "@/types/live";
|
||||||
|
|
||||||
type TrackingDetailsProps = {
|
type TrackingDetailsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -253,16 +254,25 @@ export function TrackingDetails({
|
|||||||
|
|
||||||
const [timelineSize] = useResizeObserver(timelineContainerRef);
|
const [timelineSize] = useResizeObserver(timelineContainerRef);
|
||||||
|
|
||||||
|
const [fullResolution, setFullResolution] = useState<VideoResolutionType>({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const aspectRatio = useMemo(() => {
|
const aspectRatio = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return 16 / 9;
|
return 16 / 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fullResolution.width && fullResolution.height) {
|
||||||
|
return fullResolution.width / fullResolution.height;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
config.cameras[event.camera].detect.width /
|
config.cameras[event.camera].detect.width /
|
||||||
config.cameras[event.camera].detect.height
|
config.cameras[event.camera].detect.height
|
||||||
);
|
);
|
||||||
}, [config, event]);
|
}, [config, event, fullResolution]);
|
||||||
|
|
||||||
const label = event.sub_label
|
const label = event.sub_label
|
||||||
? event.sub_label
|
? event.sub_label
|
||||||
@ -460,7 +470,7 @@ export function TrackingDetails({
|
|||||||
return "normal";
|
return "normal";
|
||||||
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
|
||||||
return "wide";
|
return "wide";
|
||||||
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
|
} else if (aspectRatio < ASPECT_PORTRAIT_LAYOUT) {
|
||||||
return "tall";
|
return "tall";
|
||||||
} else {
|
} else {
|
||||||
return "normal";
|
return "normal";
|
||||||
@ -556,6 +566,7 @@ export function TrackingDetails({
|
|||||||
onSeekToTime={handleSeekToTime}
|
onSeekToTime={handleSeekToTime}
|
||||||
onUploadFrame={onUploadFrameToPlus}
|
onUploadFrame={onUploadFrameToPlus}
|
||||||
onPlaying={() => setIsVideoLoading(false)}
|
onPlaying={() => setIsVideoLoading(false)}
|
||||||
|
setFullResolution={setFullResolution}
|
||||||
isDetailMode={true}
|
isDetailMode={true}
|
||||||
camera={event.camera}
|
camera={event.camera}
|
||||||
currentTimeOverride={currentTime}
|
currentTimeOverride={currentTime}
|
||||||
@ -623,7 +634,7 @@ export function TrackingDetails({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
isDesktop && "justify-start overflow-hidden",
|
isDesktop && "justify-start overflow-hidden",
|
||||||
aspectRatio > 1 && aspectRatio < 1.5
|
aspectRatio > 1 && aspectRatio < ASPECT_PORTRAIT_LAYOUT
|
||||||
? "lg:basis-3/5"
|
? "lg:basis-3/5"
|
||||||
: "lg:basis-2/5",
|
: "lg:basis-2/5",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { useForm, useFieldArray } from "react-hook-form";
|
import { useForm, useFieldArray } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast, Toaster } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState, useMemo, useEffect } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { LuTrash2, LuPlus } from "react-icons/lu";
|
import { LuTrash2, LuPlus } from "react-icons/lu";
|
||||||
@ -26,6 +26,7 @@ import useSWR from "swr";
|
|||||||
import { processCameraName } from "@/utils/cameraUtil";
|
import { processCameraName } from "@/utils/cameraUtil";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ConfigSetBody } from "@/types/cameraWizard";
|
import { ConfigSetBody } from "@/types/cameraWizard";
|
||||||
|
import { Toaster } from "../ui/sonner";
|
||||||
|
|
||||||
const RoleEnum = z.enum(["audio", "detect", "record"]);
|
const RoleEnum = z.enum(["audio", "detect", "record"]);
|
||||||
type Role = z.infer<typeof RoleEnum>;
|
type Role = z.infer<typeof RoleEnum>;
|
||||||
|
|||||||
@ -44,4 +44,5 @@ export type RecordingStartingPoint = {
|
|||||||
export type RecordingPlayerError = "stalled" | "startup";
|
export type RecordingPlayerError = "stalled" | "startup";
|
||||||
|
|
||||||
export const ASPECT_VERTICAL_LAYOUT = 1.5;
|
export const ASPECT_VERTICAL_LAYOUT = 1.5;
|
||||||
|
export const ASPECT_PORTRAIT_LAYOUT = 1.333;
|
||||||
export const ASPECT_WIDE_LAYOUT = 2;
|
export const ASPECT_WIDE_LAYOUT = 2;
|
||||||
|
|||||||
@ -447,7 +447,7 @@ export default function LiveDashboardView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{cameras.length == 0 && !includeBirdseye ? (
|
{cameras.length == 0 && !includeBirdseye ? (
|
||||||
<NoCameraView />
|
<NoCameraView cameraGroup={cameraGroup} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!fullscreen && events && events.length > 0 && (
|
{!fullscreen && events && events.length > 0 && (
|
||||||
@ -666,28 +666,39 @@ export default function LiveDashboardView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoCameraView() {
|
function NoCameraView({ cameraGroup }: { cameraGroup?: string }) {
|
||||||
const { t } = useTranslation(["views/live"]);
|
const { t } = useTranslation(["views/live"]);
|
||||||
const { auth } = useContext(AuthContext);
|
const { auth } = useContext(AuthContext);
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
// Check if this is a restricted user with no cameras in this group
|
const isDefault = cameraGroup === "default";
|
||||||
const isRestricted = !isAdmin && auth.isAuthenticated;
|
const isRestricted = !isAdmin && auth.isAuthenticated;
|
||||||
|
|
||||||
|
let type: "default" | "group" | "restricted";
|
||||||
|
if (isRestricted) {
|
||||||
|
type = "restricted";
|
||||||
|
} else if (isDefault) {
|
||||||
|
type = "default";
|
||||||
|
} else {
|
||||||
|
type = "group";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full items-center justify-center">
|
<div className="flex size-full items-center justify-center">
|
||||||
<EmptyCard
|
<EmptyCard
|
||||||
icon={<BsFillCameraVideoOffFill className="size-8" />}
|
icon={<BsFillCameraVideoOffFill className="size-8" />}
|
||||||
title={
|
title={t(`noCameras.${type}.title`)}
|
||||||
isRestricted ? t("noCameras.restricted.title") : t("noCameras.title")
|
description={t(`noCameras.${type}.description`)}
|
||||||
|
buttonText={
|
||||||
|
type !== "restricted" && isDefault
|
||||||
|
? t(`noCameras.${type}.buttonText`)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
description={
|
link={
|
||||||
isRestricted
|
type !== "restricted" && isDefault
|
||||||
? t("noCameras.restricted.description")
|
? "/settings?page=cameraManagement"
|
||||||
: t("noCameras.description")
|
: undefined
|
||||||
}
|
}
|
||||||
buttonText={!isRestricted ? t("noCameras.buttonText") : undefined}
|
|
||||||
link={!isRestricted ? "/settings?page=cameraManagement" : undefined}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { Toaster, toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -158,11 +159,12 @@ export default function CameraReviewSettingsView({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setChangedValue(true);
|
setChangedValue(true);
|
||||||
|
setUnsavedChanges(true);
|
||||||
setSelectDetections(isChecked as boolean);
|
setSelectDetections(isChecked as boolean);
|
||||||
},
|
},
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[watchedAlertsZones],
|
[watchedAlertsZones, setUnsavedChanges],
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveToConfig = useCallback(
|
const saveToConfig = useCallback(
|
||||||
@ -197,6 +199,8 @@ export default function CameraReviewSettingsView({
|
|||||||
position: "top-center",
|
position: "top-center",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
setChangedValue(false);
|
||||||
|
setUnsavedChanges(false);
|
||||||
updateConfig();
|
updateConfig();
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
@ -229,7 +233,14 @@ export default function CameraReviewSettingsView({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
|
[
|
||||||
|
updateConfig,
|
||||||
|
setIsLoading,
|
||||||
|
selectedCamera,
|
||||||
|
cameraConfig,
|
||||||
|
t,
|
||||||
|
setUnsavedChanges,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCancel = useCallback(() => {
|
const onCancel = useCallback(() => {
|
||||||
@ -495,6 +506,7 @@ export default function CameraReviewSettingsView({
|
|||||||
)}
|
)}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
setChangedValue(true);
|
setChangedValue(true);
|
||||||
|
setUnsavedChanges(true);
|
||||||
return checked
|
return checked
|
||||||
? field.onChange([
|
? field.onChange([
|
||||||
...field.value,
|
...field.value,
|
||||||
@ -600,6 +612,8 @@ export default function CameraReviewSettingsView({
|
|||||||
zone.name,
|
zone.name,
|
||||||
)}
|
)}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
setChangedValue(true);
|
||||||
|
setUnsavedChanges(true);
|
||||||
return checked
|
return checked
|
||||||
? field.onChange([
|
? field.onChange([
|
||||||
...field.value,
|
...field.value,
|
||||||
@ -699,7 +713,6 @@ export default function CameraReviewSettingsView({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
|
||||||
|
|
||||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||||
<Button
|
<Button
|
||||||
@ -712,7 +725,7 @@ export default function CameraReviewSettingsView({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="select"
|
variant="select"
|
||||||
disabled={isLoading}
|
disabled={!changedValue || isLoading}
|
||||||
className="flex flex-1"
|
className="flex flex-1"
|
||||||
aria-label={t("button.save", { ns: "common" })}
|
aria-label={t("button.save", { ns: "common" })}
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useCallback, useContext, useEffect, useState } from "react";
|
import { useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Separator } from "../../components/ui/separator";
|
import { Separator } from "../../components/ui/separator";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@ -664,9 +664,7 @@ export default function TriggerView({
|
|||||||
<TableHeader className="sticky top-0 bg-muted/50">
|
<TableHeader className="sticky top-0 bg-muted/50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-4"></TableHead>
|
<TableHead className="w-4"></TableHead>
|
||||||
<TableHead>
|
<TableHead>{t("triggers.table.name")}</TableHead>
|
||||||
{t("name", { ns: "triggers.table.name" })}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>{t("triggers.table.type")}</TableHead>
|
<TableHead>{t("triggers.table.type")}</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("triggers.table.lastTriggered")}
|
{t("triggers.table.lastTriggered")}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import Heading from "@/components/ui/heading";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useCallback, useContext, useEffect } from "react";
|
import { useCallback, useContext, useEffect } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Separator } from "../../components/ui/separator";
|
import { Separator } from "../../components/ui/separator";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user