mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-06 05:27:44 +03:00
Merge branch 'dev' into dev
This commit is contained in:
commit
5bc26b626b
15
.github/workflows/pull_request.yml
vendored
15
.github/workflows/pull_request.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@master
|
- uses: actions/setup-node@master
|
||||||
with:
|
with:
|
||||||
node-version: 16.x
|
node-version: 20.x
|
||||||
- name: Install devcontainer cli
|
- name: Install devcontainer cli
|
||||||
run: npm install --global @devcontainers/cli
|
run: npm install --global @devcontainers/cli
|
||||||
- name: Build devcontainer
|
- name: Build devcontainer
|
||||||
@ -64,6 +64,9 @@ jobs:
|
|||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
- run: npm install
|
- run: npm install
|
||||||
working-directory: ./web
|
working-directory: ./web
|
||||||
|
- name: Build web
|
||||||
|
run: npm run build
|
||||||
|
working-directory: ./web
|
||||||
# - name: Test
|
# - name: Test
|
||||||
# run: npm run test
|
# run: npm run test
|
||||||
# working-directory: ./web
|
# working-directory: ./web
|
||||||
@ -77,7 +80,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v5.3.0
|
uses: actions/setup-python@v5.4.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
- name: Install requirements
|
- name: Install requirements
|
||||||
@ -99,14 +102,6 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@master
|
|
||||||
with:
|
|
||||||
node-version: 16.x
|
|
||||||
- run: npm install
|
|
||||||
working-directory: ./web
|
|
||||||
- name: Build web
|
|
||||||
run: npm run build
|
|
||||||
working-directory: ./web
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
aiofiles == 24.1.*
|
aiofiles == 24.1.*
|
||||||
click == 8.1.*
|
click == 8.1.*
|
||||||
# FastAPI
|
# FastAPI
|
||||||
aiohttp == 3.11.2
|
aiohttp == 3.11.3
|
||||||
starlette == 0.41.2
|
starlette == 0.41.2
|
||||||
starlette-context == 0.3.6
|
starlette-context == 0.3.6
|
||||||
fastapi == 0.115.*
|
fastapi == 0.115.*
|
||||||
@ -20,9 +20,9 @@ pandas == 2.2.*
|
|||||||
peewee == 3.17.*
|
peewee == 3.17.*
|
||||||
peewee_migrate == 1.13.*
|
peewee_migrate == 1.13.*
|
||||||
psutil == 6.1.*
|
psutil == 6.1.*
|
||||||
pydantic == 2.8.*
|
pydantic == 2.10.*
|
||||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||||
pytz == 2024.*
|
pytz == 2025.*
|
||||||
pyzmq == 26.2.*
|
pyzmq == 26.2.*
|
||||||
ruamel.yaml == 0.18.*
|
ruamel.yaml == 0.18.*
|
||||||
tzlocal == 5.2
|
tzlocal == 5.2
|
||||||
@ -34,8 +34,8 @@ ws4py == 0.5.*
|
|||||||
unidecode == 1.3.*
|
unidecode == 1.3.*
|
||||||
# Image Manipulation
|
# Image Manipulation
|
||||||
numpy == 1.26.*
|
numpy == 1.26.*
|
||||||
opencv-python-headless == 4.10.0.*
|
opencv-python-headless == 4.11.0.*
|
||||||
opencv-contrib-python == 4.9.0.*
|
opencv-contrib-python == 4.11.0.*
|
||||||
scipy == 1.14.*
|
scipy == 1.14.*
|
||||||
# OpenVino & ONNX
|
# OpenVino & ONNX
|
||||||
openvino == 2024.4.*
|
openvino == 2024.4.*
|
||||||
@ -46,7 +46,7 @@ transformers == 4.45.*
|
|||||||
# Generative AI
|
# Generative AI
|
||||||
google-generativeai == 0.8.*
|
google-generativeai == 0.8.*
|
||||||
ollama == 0.3.*
|
ollama == 0.3.*
|
||||||
openai == 1.51.*
|
openai == 1.65.*
|
||||||
# push notifications
|
# push notifications
|
||||||
py-vapid == 1.9.*
|
py-vapid == 1.9.*
|
||||||
pywebpush == 2.0.*
|
pywebpush == 2.0.*
|
||||||
|
|||||||
@ -7,7 +7,7 @@ title: Camera Configuration
|
|||||||
|
|
||||||
Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa.
|
Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa.
|
||||||
|
|
||||||
A camera is enabled by default but can be temporarily disabled by using `enabled: False`. Existing tracked objects and recordings can still be accessed. Live streams, recording and detecting are not working. Camera specific configurations will be used.
|
A camera is enabled by default but can be disabled by using `enabled: False`. Cameras that are disabled through the configuration file will not appear in the Frigate UI and will not consume system resources.
|
||||||
|
|
||||||
Each role can only be assigned to one input per camera. The options for roles are as follows:
|
Each role can only be assigned to one input per camera. The options for roles are as follows:
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ title: Generative AI
|
|||||||
|
|
||||||
Generative AI can be used to automatically generate descriptive text based on the thumbnails of your tracked objects. This helps with [Semantic Search](/configuration/semantic_search) in Frigate to provide more context about your tracked objects. Descriptions are accessed via the _Explore_ view in the Frigate UI by clicking on a tracked object's thumbnail.
|
Generative AI can be used to automatically generate descriptive text based on the thumbnails of your tracked objects. This helps with [Semantic Search](/configuration/semantic_search) in Frigate to provide more context about your tracked objects. Descriptions are accessed via the _Explore_ view in the Frigate UI by clicking on a tracked object's thumbnail.
|
||||||
|
|
||||||
Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle. Descriptions can also be regenerated manually via the Frigate UI.
|
Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle, or can optionally be sent earlier after a number of significantly changed frames, for example in use in more real-time notifications. Descriptions can also be regenerated manually via the Frigate UI. Note that if you are manually entering a description for tracked objects prior to its end, this will be overwritten by the generated response.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@ -148,6 +148,15 @@ While generating simple descriptions of detected objects is useful, understandin
|
|||||||
|
|
||||||
Frigate provides an [MQTT topic](/integrations/mqtt), `frigate/tracked_object_update`, that is updated with a JSON payload containing `event_id` and `description` when your AI provider returns a description for a tracked object. This description could be used directly in notifications, such as sending alerts to your phone or making audio announcements. If additional details from the tracked object are needed, you can query the [HTTP API](/integrations/api/event-events-event-id-get) using the `event_id`, eg: `http://frigate_ip:5000/api/events/<event_id>`.
|
Frigate provides an [MQTT topic](/integrations/mqtt), `frigate/tracked_object_update`, that is updated with a JSON payload containing `event_id` and `description` when your AI provider returns a description for a tracked object. This description could be used directly in notifications, such as sending alerts to your phone or making audio announcements. If additional details from the tracked object are needed, you can query the [HTTP API](/integrations/api/event-events-event-id-get) using the `event_id`, eg: `http://frigate_ip:5000/api/events/<event_id>`.
|
||||||
|
|
||||||
|
If looking to get notifications earlier than when an object ceases to be tracked, an additional send trigger can be configured of `after_significant_updates`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
genai:
|
||||||
|
send_triggers:
|
||||||
|
tracked_object_end: true # default
|
||||||
|
after_significant_updates: 3 # how many updates to a tracked object before we should send an image
|
||||||
|
```
|
||||||
|
|
||||||
## Custom Prompts
|
## Custom Prompts
|
||||||
|
|
||||||
Frigate sends multiple frames from the tracked object along with a prompt to your Generative AI provider asking it to generate a description. The default prompt is as follows:
|
Frigate sends multiple frames from the tracked object along with a prompt to your Generative AI provider asking it to generate a description. The default prompt is as follows:
|
||||||
|
|||||||
@ -115,7 +115,7 @@ lpr:
|
|||||||
|
|
||||||
Ensure that:
|
Ensure that:
|
||||||
|
|
||||||
- Your camera has a clear, well-lit view of the plate.
|
- Your camera has a clear, human-readable, well-lit view of the plate. If you can't read the plate, Frigate certainly won't be able to. This may require changing video size, quality, or frame rate settings on your camera, depending on your scene and how fast the vehicles are traveling.
|
||||||
- The plate is large enough in the image (try adjusting `min_area`) or increasing the resolution of your camera's stream.
|
- The plate is large enough in the image (try adjusting `min_area`) or increasing the resolution of your camera's stream.
|
||||||
- A `car` is detected first, as LPR only runs on recognized vehicles.
|
- A `car` is detected first, as LPR only runs on recognized vehicles.
|
||||||
|
|
||||||
|
|||||||
@ -183,32 +183,46 @@ The default dashboard ("All Cameras") will always use Smart Streaming and the fi
|
|||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
### Disabling cameras
|
||||||
|
|
||||||
|
Cameras can be temporarily disabled through the Frigate UI and through [MQTT](/integrations/mqtt#frigatecamera_nameenabledset) to conserve system resources. When disabled, Frigate's ffmpeg processes are terminated — recording stops, object detection is paused, and the Live dashboard displays a blank image with a disabled message. Review items, tracked objects, and historical footage for disabled cameras can still be accessed via the UI.
|
||||||
|
|
||||||
|
For restreamed cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source).
|
||||||
|
|
||||||
|
Note that disabling a camera through the config file (`enabled: False`) removes all related UI elements, including historical footage access. To retain access while disabling the camera, keep it enabled in the config and use the UI or MQTT to disable it temporarily.
|
||||||
|
|
||||||
## Live view FAQ
|
## Live view FAQ
|
||||||
|
|
||||||
1. Why don't I have audio in my Live view?
|
1. **Why don't I have audio in my Live view?**
|
||||||
|
|
||||||
You must use go2rtc to hear audio in your live streams. If you have go2rtc already configured, you need to ensure your camera is sending PCMA/PCMU or AAC audio. If you can't change your camera's audio codec, you need to [transcode the audio](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) using go2rtc.
|
You must use go2rtc to hear audio in your live streams. If you have go2rtc already configured, you need to ensure your camera is sending PCMA/PCMU or AAC audio. If you can't change your camera's audio codec, you need to [transcode the audio](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) using go2rtc.
|
||||||
|
|
||||||
Note that the low bandwidth mode player is a video-only stream. You should not expect to hear audio when in low bandwidth mode, even if you've set up go2rtc.
|
Note that the low bandwidth mode player is a video-only stream. You should not expect to hear audio when in low bandwidth mode, even if you've set up go2rtc.
|
||||||
|
|
||||||
2. Frigate shows that my live stream is in "low bandwidth mode". What does this mean?
|
2. **Frigate shows that my live stream is in "low bandwidth mode". What does this mean?**
|
||||||
|
|
||||||
Frigate intelligently selects the live streaming technology based on a number of factors (user-selected modes like two-way talk, camera settings, browser capabilities, available bandwidth) and prioritizes showing an actual up-to-date live view of your camera's stream as quickly as possible.
|
Frigate intelligently selects the live streaming technology based on a number of factors (user-selected modes like two-way talk, camera settings, browser capabilities, available bandwidth) and prioritizes showing an actual up-to-date live view of your camera's stream as quickly as possible.
|
||||||
|
|
||||||
When you have go2rtc configured, Live view initially attempts to load and play back your stream with a clearer, fluent stream technology (MSE). An initial timeout, a low bandwidth condition that would cause buffering of the stream, or decoding errors in the stream will cause Frigate to switch to the stream defined by the `detect` role, using the jsmpeg format. This is what the UI labels as "low bandwidth mode". On Live dashboards, the mode will automatically reset when smart streaming is configured and activity stops. You can also try using the _Reset_ button to force a reload of your stream.
|
When you have go2rtc configured, Live view initially attempts to load and play back your stream with a clearer, fluent stream technology (MSE). An initial timeout, a low bandwidth condition that would cause buffering of the stream, or decoding errors in the stream will cause Frigate to switch to the stream defined by the `detect` role, using the jsmpeg format. This is what the UI labels as "low bandwidth mode". On Live dashboards, the mode will automatically reset when smart streaming is configured and activity stops. You can also try using the _Reset_ button to force a reload of your stream.
|
||||||
|
|
||||||
If you are still experiencing Frigate falling back to low bandwidth mode, you may need to adjust your camera's settings per the recommendations above or ensure you have enough bandwidth available.
|
If you are still experiencing Frigate falling back to low bandwidth mode, you may need to adjust your camera's settings per the recommendations above or ensure you have enough bandwidth available.
|
||||||
|
|
||||||
3. It doesn't seem like my cameras are streaming on the Live dashboard. Why?
|
3. **It doesn't seem like my cameras are streaming on the Live dashboard. Why?**
|
||||||
|
|
||||||
On the default Live dashboard ("All Cameras"), your camera images will update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity is detected, cameras seamlessly switch to a full-resolution live stream. If you want to customize this behavior, use a camera group.
|
On the default Live dashboard ("All Cameras"), your camera images will update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity is detected, cameras seamlessly switch to a full-resolution live stream. If you want to customize this behavior, use a camera group.
|
||||||
|
|
||||||
4. I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it?
|
4. **I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it?**
|
||||||
|
|
||||||
This is caused by incorrect dimensions set in your detect width or height (or incorrectly auto-detected), causing the jsmpeg player's rendering engine to display a slightly distorted image. You should enlarge the width and height of your `detect` resolution up to a standard aspect ratio (example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). If changing the resolution to match a standard (4:3, 16:9, or 32:9, etc) aspect ratio does not solve the issue, you can enable "compatibility mode" in your camera group dashboard's stream settings. Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your `detect` width and height fails to resolve the color artifacts and diagonal line.
|
This is caused by incorrect dimensions set in your detect width or height (or incorrectly auto-detected), causing the jsmpeg player's rendering engine to display a slightly distorted image. You should enlarge the width and height of your `detect` resolution up to a standard aspect ratio (example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). If changing the resolution to match a standard (4:3, 16:9, or 32:9, etc) aspect ratio does not solve the issue, you can enable "compatibility mode" in your camera group dashboard's stream settings. Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your `detect` width and height fails to resolve the color artifacts and diagonal line.
|
||||||
|
|
||||||
5. How does "smart streaming" work?
|
5. **How does "smart streaming" work?**
|
||||||
|
|
||||||
Because a static image of a scene looks exactly the same as a live stream with no motion or activity, smart streaming updates your camera images once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity (motion or object/audio detection) occurs, cameras seamlessly switch to a live stream.
|
Because a static image of a scene looks exactly the same as a live stream with no motion or activity, smart streaming updates your camera images once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity (motion or object/audio detection) occurs, cameras seamlessly switch to a live stream.
|
||||||
|
|
||||||
This static image is pulled from the stream defined in your config with the `detect` role. When activity is detected, images from the `detect` stream immediately begin updating at ~5 frames per second so you can see the activity until the live player is loaded and begins playing. This usually only takes a second or two. If the live player times out, buffers, or has streaming errors, the jsmpeg player is loaded and plays a video-only stream from the `detect` role. When activity ends, the players are destroyed and a static image is displayed until activity is detected again, and the process repeats.
|
This static image is pulled from the stream defined in your config with the `detect` role. When activity is detected, images from the `detect` stream immediately begin updating at ~5 frames per second so you can see the activity until the live player is loaded and begins playing. This usually only takes a second or two. If the live player times out, buffers, or has streaming errors, the jsmpeg player is loaded and plays a video-only stream from the `detect` role. When activity ends, the players are destroyed and a static image is displayed until activity is detected again, and the process repeats.
|
||||||
|
|
||||||
This is Frigate's default and recommended setting because it results in a significant bandwidth savings, especially for high resolution cameras.
|
This is Frigate's default and recommended setting because it results in a significant bandwidth savings, especially for high resolution cameras.
|
||||||
|
|
||||||
6. I have unmuted some cameras on my dashboard, but I do not hear sound. Why?
|
6. **I have unmuted some cameras on my dashboard, but I do not hear sound. Why?**
|
||||||
|
|
||||||
If your camera is streaming (as indicated by a red dot in the upper right, or if it has been set to continuous streaming mode), your browser may be blocking audio until you interact with the page. This is an intentional browser limitation. See [this article](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability). Many browsers have a whitelist feature to change this behavior.
|
If your camera is streaming (as indicated by a red dot in the upper right, or if it has been set to continuous streaming mode), your browser may be blocking audio until you interact with the page. This is an intentional browser limitation. See [this article](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability). Many browsers have a whitelist feature to change this behavior.
|
||||||
|
|||||||
@ -230,7 +230,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
|
|||||||
|
|
||||||
#### YOLOv9
|
#### YOLOv9
|
||||||
|
|
||||||
[YOLOv9](https://github.com/MultimediaTechLab/YOLO) models are supported, but not included by default.
|
[YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default.
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
|
|
||||||
@ -513,7 +513,7 @@ model:
|
|||||||
|
|
||||||
#### YOLOv9
|
#### YOLOv9
|
||||||
|
|
||||||
[YOLOv9](https://github.com/MultimediaTechLab/YOLO) models are supported, but not included by default.
|
[YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default.
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
|
|
||||||
|
|||||||
@ -183,6 +183,8 @@ record:
|
|||||||
sync_recordings: True
|
sync_recordings: True
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This feature is meant to fix variations in files, not completely delete entries in the database. If you delete all of your media, don't use `sync_recordings`, just stop Frigate, delete the `frigate.db` database, and restart.
|
||||||
|
|
||||||
:::warning
|
:::warning
|
||||||
|
|
||||||
The sync operation uses considerable CPU resources and in most cases is not needed, only enable when necessary.
|
The sync operation uses considerable CPU resources and in most cases is not needed, only enable when necessary.
|
||||||
|
|||||||
@ -255,6 +255,8 @@ ffmpeg:
|
|||||||
# Optional: Detect configuration
|
# Optional: Detect configuration
|
||||||
# NOTE: Can be overridden at the camera level
|
# NOTE: Can be overridden at the camera level
|
||||||
detect:
|
detect:
|
||||||
|
# Optional: enables detection for the camera (default: shown below)
|
||||||
|
enabled: False
|
||||||
# Optional: width of the frame for the input with the detect role (default: use native stream resolution)
|
# Optional: width of the frame for the input with the detect role (default: use native stream resolution)
|
||||||
width: 1280
|
width: 1280
|
||||||
# Optional: height of the frame for the input with the detect role (default: use native stream resolution)
|
# Optional: height of the frame for the input with the detect role (default: use native stream resolution)
|
||||||
@ -262,8 +264,6 @@ detect:
|
|||||||
# Optional: desired fps for your camera for the input with the detect role (default: shown below)
|
# Optional: desired fps for your camera for the input with the detect role (default: shown below)
|
||||||
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
|
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
|
||||||
fps: 5
|
fps: 5
|
||||||
# Optional: enables detection for the camera (default: True)
|
|
||||||
enabled: True
|
|
||||||
# Optional: Number of consecutive detection hits required for an object to be initialized in the tracker. (default: 1/2 the frame rate)
|
# Optional: Number of consecutive detection hits required for an object to be initialized in the tracker. (default: 1/2 the frame rate)
|
||||||
min_initialized: 2
|
min_initialized: 2
|
||||||
# Optional: Number of frames without a detection before Frigate considers an object to be gone. (default: 5x the frame rate)
|
# Optional: Number of frames without a detection before Frigate considers an object to be gone. (default: 5x the frame rate)
|
||||||
@ -813,6 +813,12 @@ cameras:
|
|||||||
- cat
|
- cat
|
||||||
# Optional: Restrict generation to objects that entered any of the listed zones (default: none, all zones qualify)
|
# Optional: Restrict generation to objects that entered any of the listed zones (default: none, all zones qualify)
|
||||||
required_zones: []
|
required_zones: []
|
||||||
|
# Optional: What triggers to use to send frames for a tracked object to generative AI (default: shown below)
|
||||||
|
send_triggers:
|
||||||
|
# Once the object is no longer tracked
|
||||||
|
tracked_object_end: True
|
||||||
|
# Optional: After X many significant updates are received (default: shown below)
|
||||||
|
after_significant_updates: None
|
||||||
# Optional: Save thumbnails sent to generative AI for review/debugging purposes (default: shown below)
|
# Optional: Save thumbnails sent to generative AI for review/debugging purposes (default: shown below)
|
||||||
debug_save_thumbnails: False
|
debug_save_thumbnails: False
|
||||||
|
|
||||||
|
|||||||
@ -151,8 +151,6 @@ cameras:
|
|||||||
- path: rtsp://10.0.10.10:554/rtsp # <----- The stream you want to use for detection
|
- path: rtsp://10.0.10.10:554/rtsp # <----- The stream you want to use for detection
|
||||||
roles:
|
roles:
|
||||||
- detect
|
- detect
|
||||||
detect:
|
|
||||||
enabled: False # <---- disable detection until you have a working camera feed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Start Frigate
|
### Step 2: Start Frigate
|
||||||
|
|||||||
@ -32,6 +32,7 @@ class StationaryConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class DetectConfig(FrigateBaseModel):
|
class DetectConfig(FrigateBaseModel):
|
||||||
|
enabled: bool = Field(default=False, title="Detection Enabled.")
|
||||||
height: Optional[int] = Field(
|
height: Optional[int] = Field(
|
||||||
default=None, title="Height of the stream for the detect role."
|
default=None, title="Height of the stream for the detect role."
|
||||||
)
|
)
|
||||||
@ -41,7 +42,6 @@ class DetectConfig(FrigateBaseModel):
|
|||||||
fps: int = Field(
|
fps: int = Field(
|
||||||
default=5, title="Number of frames per second to process through detection."
|
default=5, title="Number of frames per second to process through detection."
|
||||||
)
|
)
|
||||||
enabled: bool = Field(default=True, title="Detection Enabled.")
|
|
||||||
min_initialized: Optional[int] = Field(
|
min_initialized: Optional[int] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
title="Minimum number of consecutive hits for an object to be initialized by the tracker.",
|
title="Minimum number of consecutive hits for an object to be initialized by the tracker.",
|
||||||
|
|||||||
@ -16,6 +16,17 @@ class GenAIProviderEnum(str, Enum):
|
|||||||
ollama = "ollama"
|
ollama = "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
class GenAISendTriggersConfig(BaseModel):
|
||||||
|
tracked_object_end: bool = Field(
|
||||||
|
default=True, title="Send once the object is no longer tracked."
|
||||||
|
)
|
||||||
|
after_significant_updates: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
title="Send an early request to generative AI when X frames accumulated.",
|
||||||
|
ge=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# uses BaseModel because some global attributes are not available at the camera level
|
# uses BaseModel because some global attributes are not available at the camera level
|
||||||
class GenAICameraConfig(BaseModel):
|
class GenAICameraConfig(BaseModel):
|
||||||
enabled: bool = Field(default=False, title="Enable GenAI for camera.")
|
enabled: bool = Field(default=False, title="Enable GenAI for camera.")
|
||||||
@ -42,6 +53,10 @@ class GenAICameraConfig(BaseModel):
|
|||||||
default=False,
|
default=False,
|
||||||
title="Save thumbnails sent to generative AI for debugging purposes.",
|
title="Save thumbnails sent to generative AI for debugging purposes.",
|
||||||
)
|
)
|
||||||
|
send_triggers: GenAISendTriggersConfig = Field(
|
||||||
|
default_factory=GenAISendTriggersConfig,
|
||||||
|
title="What triggers to use to send frames to generative AI for a tracked object.",
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("required_zones", mode="before")
|
@field_validator("required_zones", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@ -48,7 +48,11 @@ from frigate.genai import get_genai_client
|
|||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||||
from frigate.util.builtin import serialize
|
from frigate.util.builtin import serialize
|
||||||
from frigate.util.image import SharedMemoryFrameManager, calculate_region
|
from frigate.util.image import (
|
||||||
|
SharedMemoryFrameManager,
|
||||||
|
calculate_region,
|
||||||
|
ensure_jpeg_bytes,
|
||||||
|
)
|
||||||
from frigate.util.path import get_event_thumbnail_bytes
|
from frigate.util.path import get_event_thumbnail_bytes
|
||||||
|
|
||||||
from .embeddings import Embeddings
|
from .embeddings import Embeddings
|
||||||
@ -128,6 +132,7 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.tracked_events: dict[str, list[any]] = {}
|
self.tracked_events: dict[str, list[any]] = {}
|
||||||
|
self.early_request_sent: dict[str, bool] = {}
|
||||||
self.genai_client = get_genai_client(config)
|
self.genai_client = get_genai_client(config)
|
||||||
|
|
||||||
# recordings data
|
# recordings data
|
||||||
@ -236,6 +241,43 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
self.tracked_events[data["id"]].append(data)
|
self.tracked_events[data["id"]].append(data)
|
||||||
|
|
||||||
|
# check if we're configured to send an early request after a minimum number of updates received
|
||||||
|
if (
|
||||||
|
self.genai_client is not None
|
||||||
|
and camera_config.genai.send_triggers.after_significant_updates
|
||||||
|
):
|
||||||
|
if (
|
||||||
|
len(self.tracked_events.get(data["id"], []))
|
||||||
|
>= camera_config.genai.send_triggers.after_significant_updates
|
||||||
|
and data["id"] not in self.early_request_sent
|
||||||
|
):
|
||||||
|
if data["has_clip"] and data["has_snapshot"]:
|
||||||
|
event: Event = Event.get(Event.id == data["id"])
|
||||||
|
|
||||||
|
if (
|
||||||
|
not camera_config.genai.objects
|
||||||
|
or event.label in camera_config.genai.objects
|
||||||
|
) and (
|
||||||
|
not camera_config.genai.required_zones
|
||||||
|
or set(data["entered_zones"])
|
||||||
|
& set(camera_config.genai.required_zones)
|
||||||
|
):
|
||||||
|
logger.debug(f"{camera} sending early request to GenAI")
|
||||||
|
|
||||||
|
self.early_request_sent[data["id"]] = True
|
||||||
|
threading.Thread(
|
||||||
|
target=self._genai_embed_description,
|
||||||
|
name=f"_genai_embed_description_{event.id}",
|
||||||
|
daemon=True,
|
||||||
|
args=(
|
||||||
|
event,
|
||||||
|
[
|
||||||
|
data["thumbnail"]
|
||||||
|
for data in self.tracked_events[data["id"]]
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).start()
|
||||||
|
|
||||||
self.frame_manager.close(frame_name)
|
self.frame_manager.close(frame_name)
|
||||||
|
|
||||||
def _process_finalized(self) -> None:
|
def _process_finalized(self) -> None:
|
||||||
@ -296,8 +338,8 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
# Run GenAI
|
# Run GenAI
|
||||||
if (
|
if (
|
||||||
camera_config.genai.enabled
|
camera_config.genai.enabled
|
||||||
|
and camera_config.genai.send_triggers.tracked_object_end
|
||||||
and self.genai_client is not None
|
and self.genai_client is not None
|
||||||
and event.data.get("description") is None
|
|
||||||
and (
|
and (
|
||||||
not camera_config.genai.objects
|
not camera_config.genai.objects
|
||||||
or event.label in camera_config.genai.objects
|
or event.label in camera_config.genai.objects
|
||||||
@ -374,6 +416,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
num_thumbnails = len(self.tracked_events.get(event.id, []))
|
num_thumbnails = len(self.tracked_events.get(event.id, []))
|
||||||
|
|
||||||
|
# ensure we have a jpeg to pass to the model
|
||||||
|
thumbnail = ensure_jpeg_bytes(thumbnail)
|
||||||
|
|
||||||
embed_image = (
|
embed_image = (
|
||||||
[snapshot_image]
|
[snapshot_image]
|
||||||
if event.has_snapshot and camera_config.genai.use_snapshot
|
if event.has_snapshot and camera_config.genai.use_snapshot
|
||||||
@ -503,6 +548,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
|
|
||||||
thumbnail = get_event_thumbnail_bytes(event)
|
thumbnail = get_event_thumbnail_bytes(event)
|
||||||
|
|
||||||
|
# ensure we have a jpeg to pass to the model
|
||||||
|
thumbnail = ensure_jpeg_bytes(thumbnail)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Trying {source} regeneration for {event}, has_snapshot: {event.has_snapshot}"
|
f"Trying {source} regeneration for {event}, has_snapshot: {event.has_snapshot}"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -135,8 +135,13 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
# create communication for audio detections
|
# create communication for audio detections
|
||||||
self.requestor = InterProcessRequestor()
|
self.requestor = InterProcessRequestor()
|
||||||
self.config_subscriber = ConfigSubscriber(f"config/audio/{camera.name}")
|
self.config_subscriber = ConfigSubscriber(f"config/audio/{camera.name}")
|
||||||
|
self.enabled_subscriber = ConfigSubscriber(
|
||||||
|
f"config/enabled/{camera.name}", True
|
||||||
|
)
|
||||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio)
|
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio)
|
||||||
|
|
||||||
|
self.was_enabled = camera.enabled
|
||||||
|
|
||||||
def detect_audio(self, audio) -> None:
|
def detect_audio(self, audio) -> None:
|
||||||
if not self.config.audio.enabled or self.stop_event.is_set():
|
if not self.config.audio.enabled or self.stop_event.is_set():
|
||||||
return
|
return
|
||||||
@ -248,6 +253,23 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
f"Failed to end audio event {detection['id']} with status code {resp.status_code}"
|
f"Failed to end audio event {detection['id']} with status code {resp.status_code}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def expire_all_detections(self) -> None:
|
||||||
|
"""Immediately end all current detections"""
|
||||||
|
now = datetime.datetime.now().timestamp()
|
||||||
|
for label, detection in list(self.detections.items()):
|
||||||
|
if detection:
|
||||||
|
self.requestor.send_data(f"{self.config.name}/audio/{label}", "OFF")
|
||||||
|
resp = requests.put(
|
||||||
|
f"{FRIGATE_LOCALHOST}/api/events/{detection['id']}/end",
|
||||||
|
json={"end_time": now},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
self.detections[label] = None
|
||||||
|
else:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Failed to end audio event {detection['id']} with status code {resp.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
def start_or_restart_ffmpeg(self) -> None:
|
def start_or_restart_ffmpeg(self) -> None:
|
||||||
self.audio_listener = start_or_restart_ffmpeg(
|
self.audio_listener = start_or_restart_ffmpeg(
|
||||||
self.ffmpeg_cmd,
|
self.ffmpeg_cmd,
|
||||||
@ -283,10 +305,41 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
self.logger.error(f"Error reading audio data from ffmpeg process: {e}")
|
self.logger.error(f"Error reading audio data from ffmpeg process: {e}")
|
||||||
log_and_restart()
|
log_and_restart()
|
||||||
|
|
||||||
|
def _update_enabled_state(self) -> bool:
|
||||||
|
"""Fetch the latest config and update enabled state."""
|
||||||
|
_, config_data = self.enabled_subscriber.check_for_update()
|
||||||
|
if config_data:
|
||||||
|
self.config.enabled = config_data.enabled
|
||||||
|
return config_data.enabled
|
||||||
|
|
||||||
|
return self.config.enabled
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
|
if self._update_enabled_state():
|
||||||
self.start_or_restart_ffmpeg()
|
self.start_or_restart_ffmpeg()
|
||||||
|
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
|
enabled = self._update_enabled_state()
|
||||||
|
if enabled != self.was_enabled:
|
||||||
|
if enabled:
|
||||||
|
self.logger.debug(
|
||||||
|
f"Enabling audio detections for {self.config.name}"
|
||||||
|
)
|
||||||
|
self.start_or_restart_ffmpeg()
|
||||||
|
else:
|
||||||
|
self.logger.debug(
|
||||||
|
f"Disabling audio detections for {self.config.name}, ending events"
|
||||||
|
)
|
||||||
|
self.expire_all_detections()
|
||||||
|
stop_ffmpeg(self.audio_listener, self.logger)
|
||||||
|
self.audio_listener = None
|
||||||
|
self.was_enabled = enabled
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
# check if there is an updated config
|
# check if there is an updated config
|
||||||
(
|
(
|
||||||
updated_topic,
|
updated_topic,
|
||||||
@ -302,6 +355,7 @@ class AudioEventMaintainer(threading.Thread):
|
|||||||
self.logpipe.close()
|
self.logpipe.close()
|
||||||
self.requestor.stop()
|
self.requestor.stop()
|
||||||
self.config_subscriber.stop()
|
self.config_subscriber.stop()
|
||||||
|
self.enabled_subscriber.stop()
|
||||||
self.detection_publisher.stop()
|
self.detection_publisher.stop()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -440,10 +440,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.last_motion_detected: dict[str, float] = {}
|
self.last_motion_detected: dict[str, float] = {}
|
||||||
self.ptz_autotracker_thread = ptz_autotracker_thread
|
self.ptz_autotracker_thread = ptz_autotracker_thread
|
||||||
|
|
||||||
self.enabled_subscribers = {
|
self.config_enabled_subscriber = ConfigSubscriber("config/enabled/")
|
||||||
camera: ConfigSubscriber(f"config/enabled/{camera}", True)
|
|
||||||
for camera in config.cameras.keys()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.requestor = InterProcessRequestor()
|
self.requestor = InterProcessRequestor()
|
||||||
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video)
|
||||||
@ -705,24 +702,34 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
{"enabled": False, "motion": 0, "objects": []},
|
{"enabled": False, "motion": 0, "objects": []},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_enabled_state(self, camera: str) -> bool:
|
|
||||||
_, config_data = self.enabled_subscribers[camera].check_for_update()
|
|
||||||
|
|
||||||
if config_data:
|
|
||||||
self.config.cameras[camera].enabled = config_data.enabled
|
|
||||||
|
|
||||||
if self.camera_states[camera].prev_enabled is None:
|
|
||||||
self.camera_states[camera].prev_enabled = config_data.enabled
|
|
||||||
|
|
||||||
return self.config.cameras[camera].enabled
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
|
# check for config updates
|
||||||
|
while True:
|
||||||
|
(
|
||||||
|
updated_enabled_topic,
|
||||||
|
updated_enabled_config,
|
||||||
|
) = self.config_enabled_subscriber.check_for_update()
|
||||||
|
|
||||||
|
if not updated_enabled_topic:
|
||||||
|
break
|
||||||
|
|
||||||
|
camera_name = updated_enabled_topic.rpartition("/")[-1]
|
||||||
|
self.config.cameras[
|
||||||
|
camera_name
|
||||||
|
].enabled = updated_enabled_config.enabled
|
||||||
|
|
||||||
|
if self.camera_states[camera_name].prev_enabled is None:
|
||||||
|
self.camera_states[
|
||||||
|
camera_name
|
||||||
|
].prev_enabled = updated_enabled_config.enabled
|
||||||
|
|
||||||
|
# manage camera disabled state
|
||||||
for camera, config in self.config.cameras.items():
|
for camera, config in self.config.cameras.items():
|
||||||
if not config.enabled_in_config:
|
if not config.enabled_in_config:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
current_enabled = self._get_enabled_state(camera)
|
current_enabled = config.enabled
|
||||||
camera_state = self.camera_states[camera]
|
camera_state = self.camera_states[camera]
|
||||||
|
|
||||||
if camera_state.prev_enabled and not current_enabled:
|
if camera_state.prev_enabled and not current_enabled:
|
||||||
@ -746,7 +753,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not self._get_enabled_state(camera):
|
if not self.config.cameras[camera].enabled:
|
||||||
logger.debug(f"Camera {camera} disabled, skipping update")
|
logger.debug(f"Camera {camera} disabled, skipping update")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -792,7 +799,6 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.detection_publisher.stop()
|
self.detection_publisher.stop()
|
||||||
self.event_sender.stop()
|
self.event_sender.stop()
|
||||||
self.event_end_subscriber.stop()
|
self.event_end_subscriber.stop()
|
||||||
for subscriber in self.enabled_subscribers.values():
|
self.config_enabled_subscriber.stop()
|
||||||
subscriber.stop()
|
|
||||||
|
|
||||||
logger.info("Exiting object processor...")
|
logger.info("Exiting object processor...")
|
||||||
|
|||||||
@ -281,12 +281,6 @@ class BirdsEyeFrameManager:
|
|||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.inactivity_threshold = config.birdseye.inactivity_threshold
|
self.inactivity_threshold = config.birdseye.inactivity_threshold
|
||||||
|
|
||||||
self.enabled_subscribers = {
|
|
||||||
cam: ConfigSubscriber(f"config/enabled/{cam}", True)
|
|
||||||
for cam in config.cameras.keys()
|
|
||||||
if config.cameras[cam].enabled_in_config
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.birdseye.layout.max_cameras:
|
if config.birdseye.layout.max_cameras:
|
||||||
self.last_refresh_time = 0
|
self.last_refresh_time = 0
|
||||||
|
|
||||||
@ -387,16 +381,6 @@ class BirdsEyeFrameManager:
|
|||||||
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
|
if mode == BirdseyeModeEnum.objects and object_box_count > 0:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _get_enabled_state(self, camera: str) -> bool:
|
|
||||||
"""Fetch the latest enabled state for a camera from ZMQ."""
|
|
||||||
_, config_data = self.enabled_subscribers[camera].check_for_update()
|
|
||||||
|
|
||||||
if config_data:
|
|
||||||
self.config.cameras[camera].enabled = config_data.enabled
|
|
||||||
return config_data.enabled
|
|
||||||
|
|
||||||
return self.config.cameras[camera].enabled
|
|
||||||
|
|
||||||
def update_frame(self, frame: Optional[np.ndarray] = None) -> bool:
|
def update_frame(self, frame: Optional[np.ndarray] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Update birdseye, optionally with a new frame.
|
Update birdseye, optionally with a new frame.
|
||||||
@ -410,7 +394,7 @@ class BirdsEyeFrameManager:
|
|||||||
for cam, cam_data in self.cameras.items()
|
for cam, cam_data in self.cameras.items()
|
||||||
if self.config.cameras[cam].birdseye.enabled
|
if self.config.cameras[cam].birdseye.enabled
|
||||||
and self.config.cameras[cam].enabled_in_config
|
and self.config.cameras[cam].enabled_in_config
|
||||||
and self._get_enabled_state(cam)
|
and self.config.cameras[cam].enabled
|
||||||
and cam_data["last_active_frame"] > 0
|
and cam_data["last_active_frame"] > 0
|
||||||
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
and cam_data["current_frame_time"] - cam_data["last_active_frame"]
|
||||||
< self.inactivity_threshold
|
< self.inactivity_threshold
|
||||||
@ -706,11 +690,11 @@ class BirdsEyeFrameManager:
|
|||||||
frame: np.ndarray,
|
frame: np.ndarray,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
# don't process if birdseye is disabled for this camera
|
# don't process if birdseye is disabled for this camera
|
||||||
camera_config = self.config.cameras[camera].birdseye
|
camera_config = self.config.cameras[camera]
|
||||||
force_update = False
|
force_update = False
|
||||||
|
|
||||||
# disabling birdseye is a little tricky
|
# disabling birdseye is a little tricky
|
||||||
if not self._get_enabled_state(camera):
|
if not camera_config.birdseye.enabled or not camera_config.enabled:
|
||||||
# if we've rendered a frame (we have a value for last_active_frame)
|
# if we've rendered a frame (we have a value for last_active_frame)
|
||||||
# then we need to set it to zero
|
# then we need to set it to zero
|
||||||
if self.cameras[camera]["last_active_frame"] > 0:
|
if self.cameras[camera]["last_active_frame"] > 0:
|
||||||
@ -722,7 +706,7 @@ class BirdsEyeFrameManager:
|
|||||||
# update the last active frame for the camera
|
# update the last active frame for the camera
|
||||||
self.cameras[camera]["current_frame"] = frame.copy()
|
self.cameras[camera]["current_frame"] = frame.copy()
|
||||||
self.cameras[camera]["current_frame_time"] = frame_time
|
self.cameras[camera]["current_frame_time"] = frame_time
|
||||||
if self.camera_active(camera_config.mode, object_count, motion_count):
|
if self.camera_active(camera_config.birdseye.mode, object_count, motion_count):
|
||||||
self.cameras[camera]["last_active_frame"] = frame_time
|
self.cameras[camera]["last_active_frame"] = frame_time
|
||||||
|
|
||||||
now = datetime.datetime.now().timestamp()
|
now = datetime.datetime.now().timestamp()
|
||||||
@ -745,11 +729,6 @@ class BirdsEyeFrameManager:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Clean up subscribers when stopping."""
|
|
||||||
for subscriber in self.enabled_subscribers.values():
|
|
||||||
subscriber.stop()
|
|
||||||
|
|
||||||
|
|
||||||
class Birdseye:
|
class Birdseye:
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -775,7 +754,8 @@ class Birdseye:
|
|||||||
"birdseye", self.converter, websocket_server, stop_event
|
"birdseye", self.converter, websocket_server, stop_event
|
||||||
)
|
)
|
||||||
self.birdseye_manager = BirdsEyeFrameManager(config, stop_event)
|
self.birdseye_manager = BirdsEyeFrameManager(config, stop_event)
|
||||||
self.config_subscriber = ConfigSubscriber("config/birdseye/")
|
self.config_enabled_subscriber = ConfigSubscriber("config/enabled/")
|
||||||
|
self.birdseye_subscriber = ConfigSubscriber("config/birdseye/")
|
||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
|
||||||
@ -815,16 +795,28 @@ class Birdseye:
|
|||||||
# check if there is an updated config
|
# check if there is an updated config
|
||||||
while True:
|
while True:
|
||||||
(
|
(
|
||||||
updated_topic,
|
updated_birdseye_topic,
|
||||||
updated_birdseye_config,
|
updated_birdseye_config,
|
||||||
) = self.config_subscriber.check_for_update()
|
) = self.birdseye_subscriber.check_for_update()
|
||||||
|
|
||||||
if not updated_topic:
|
(
|
||||||
|
updated_enabled_topic,
|
||||||
|
updated_enabled_config,
|
||||||
|
) = self.config_enabled_subscriber.check_for_update()
|
||||||
|
|
||||||
|
if not updated_birdseye_topic and not updated_enabled_topic:
|
||||||
break
|
break
|
||||||
|
|
||||||
camera_name = updated_topic.rpartition("/")[-1]
|
if updated_birdseye_config:
|
||||||
|
camera_name = updated_birdseye_topic.rpartition("/")[-1]
|
||||||
self.config.cameras[camera_name].birdseye = updated_birdseye_config
|
self.config.cameras[camera_name].birdseye = updated_birdseye_config
|
||||||
|
|
||||||
|
if updated_enabled_config:
|
||||||
|
camera_name = updated_enabled_topic.rpartition("/")[-1]
|
||||||
|
self.config.cameras[
|
||||||
|
camera_name
|
||||||
|
].enabled = updated_enabled_config.enabled
|
||||||
|
|
||||||
if self.birdseye_manager.update(
|
if self.birdseye_manager.update(
|
||||||
camera,
|
camera,
|
||||||
len([o for o in current_tracked_objects if not o["stationary"]]),
|
len([o for o in current_tracked_objects if not o["stationary"]]),
|
||||||
@ -835,7 +827,7 @@ class Birdseye:
|
|||||||
self.__send_new_frame()
|
self.__send_new_frame()
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.config_subscriber.stop()
|
self.birdseye_subscriber.stop()
|
||||||
self.birdseye_manager.stop()
|
self.config_enabled_subscriber.stop()
|
||||||
self.converter.join()
|
self.converter.join()
|
||||||
self.broadcaster.join()
|
self.broadcaster.join()
|
||||||
|
|||||||
@ -41,23 +41,31 @@ def check_disabled_camera_update(
|
|||||||
has_enabled_camera = False
|
has_enabled_camera = False
|
||||||
|
|
||||||
for camera, last_update in write_times.items():
|
for camera, last_update in write_times.items():
|
||||||
|
offline_time = now - last_update
|
||||||
|
|
||||||
if config.cameras[camera].enabled:
|
if config.cameras[camera].enabled:
|
||||||
has_enabled_camera = True
|
has_enabled_camera = True
|
||||||
|
else:
|
||||||
|
# flag camera as offline when it is disabled
|
||||||
|
previews[camera].flag_offline(now)
|
||||||
|
|
||||||
if now - last_update > 1:
|
if offline_time > 1:
|
||||||
# last camera update was more than one second ago
|
# last camera update was more than 1 second ago
|
||||||
# need to send empty data to updaters because current
|
# need to send empty data to birdseye because current
|
||||||
# frame is now out of date
|
# frame is now out of date
|
||||||
frame = get_blank_yuv_frame(
|
if birdseye and offline_time < 10:
|
||||||
|
# we only need to send blank frames to birdseye at the beginning of a camera being offline
|
||||||
|
birdseye.write_data(
|
||||||
|
camera,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
now,
|
||||||
|
get_blank_yuv_frame(
|
||||||
config.cameras[camera].detect.width,
|
config.cameras[camera].detect.width,
|
||||||
config.cameras[camera].detect.height,
|
config.cameras[camera].detect.height,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if birdseye:
|
|
||||||
birdseye.write_data(camera, [], [], now, frame)
|
|
||||||
|
|
||||||
previews[camera].write_data([], [], now, frame)
|
|
||||||
|
|
||||||
if not has_enabled_camera and birdseye:
|
if not has_enabled_camera and birdseye:
|
||||||
birdseye.all_cameras_disabled()
|
birdseye.all_cameras_disabled()
|
||||||
|
|
||||||
@ -170,6 +178,12 @@ def output_frames(
|
|||||||
else:
|
else:
|
||||||
failed_frame_requests[camera] = 0
|
failed_frame_requests[camera] = 0
|
||||||
|
|
||||||
|
# send frames for low fps recording
|
||||||
|
preview_recorders[camera].write_data(
|
||||||
|
current_tracked_objects, motion_boxes, frame_time, frame
|
||||||
|
)
|
||||||
|
preview_write_times[camera] = frame_time
|
||||||
|
|
||||||
# send camera frame to ffmpeg process if websockets are connected
|
# send camera frame to ffmpeg process if websockets are connected
|
||||||
if any(
|
if any(
|
||||||
ws.environ["PATH_INFO"].endswith(camera) for ws in websocket_server.manager
|
ws.environ["PATH_INFO"].endswith(camera) for ws in websocket_server.manager
|
||||||
@ -193,11 +207,6 @@ def output_frames(
|
|||||||
frame,
|
frame,
|
||||||
)
|
)
|
||||||
|
|
||||||
# send frames for low fps recording
|
|
||||||
preview_recorders[camera].write_data(
|
|
||||||
current_tracked_objects, motion_boxes, frame_time, frame
|
|
||||||
)
|
|
||||||
preview_write_times[camera] = frame_time
|
|
||||||
frame_manager.close(frame_name)
|
frame_manager.close(frame_name)
|
||||||
|
|
||||||
move_preview_frames("clips")
|
move_preview_frames("clips")
|
||||||
|
|||||||
@ -23,7 +23,7 @@ from frigate.ffmpeg_presets import (
|
|||||||
)
|
)
|
||||||
from frigate.models import Previews
|
from frigate.models import Previews
|
||||||
from frigate.object_processing import TrackedObject
|
from frigate.object_processing import TrackedObject
|
||||||
from frigate.util.image import copy_yuv_to_position, get_yuv_crop
|
from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -153,6 +153,7 @@ class PreviewRecorder:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.start_time = 0
|
self.start_time = 0
|
||||||
self.last_output_time = 0
|
self.last_output_time = 0
|
||||||
|
self.offline = False
|
||||||
self.output_frames = []
|
self.output_frames = []
|
||||||
|
|
||||||
if config.detect.width > config.detect.height:
|
if config.detect.width > config.detect.height:
|
||||||
@ -241,6 +242,17 @@ class PreviewRecorder:
|
|||||||
self.last_output_time = ts
|
self.last_output_time = ts
|
||||||
self.output_frames.append(ts)
|
self.output_frames.append(ts)
|
||||||
|
|
||||||
|
def reset_frame_cache(self, frame_time: float) -> None:
|
||||||
|
self.segment_end = (
|
||||||
|
(datetime.datetime.now() + datetime.timedelta(hours=1))
|
||||||
|
.astimezone(datetime.timezone.utc)
|
||||||
|
.replace(minute=0, second=0, microsecond=0)
|
||||||
|
.timestamp()
|
||||||
|
)
|
||||||
|
self.start_time = frame_time
|
||||||
|
self.last_output_time = frame_time
|
||||||
|
self.output_frames: list[float] = []
|
||||||
|
|
||||||
def should_write_frame(
|
def should_write_frame(
|
||||||
self,
|
self,
|
||||||
current_tracked_objects: list[dict[str, any]],
|
current_tracked_objects: list[dict[str, any]],
|
||||||
@ -307,7 +319,9 @@ class PreviewRecorder:
|
|||||||
motion_boxes: list[list[int]],
|
motion_boxes: list[list[int]],
|
||||||
frame_time: float,
|
frame_time: float,
|
||||||
frame: np.ndarray,
|
frame: np.ndarray,
|
||||||
) -> bool:
|
) -> None:
|
||||||
|
self.offline = False
|
||||||
|
|
||||||
# check for updated record config
|
# check for updated record config
|
||||||
_, updated_record_config = self.config_subscriber.check_for_update()
|
_, updated_record_config = self.config_subscriber.check_for_update()
|
||||||
|
|
||||||
@ -319,7 +333,7 @@ class PreviewRecorder:
|
|||||||
self.start_time = frame_time
|
self.start_time = frame_time
|
||||||
self.output_frames.append(frame_time)
|
self.output_frames.append(frame_time)
|
||||||
self.write_frame_to_cache(frame_time, frame)
|
self.write_frame_to_cache(frame_time, frame)
|
||||||
return False
|
return
|
||||||
|
|
||||||
# check if PREVIEW clip should be generated and cached frames reset
|
# check if PREVIEW clip should be generated and cached frames reset
|
||||||
if frame_time >= self.segment_end:
|
if frame_time >= self.segment_end:
|
||||||
@ -340,32 +354,35 @@ class PreviewRecorder:
|
|||||||
f"Not saving preview for {self.config.name} because there are no saved frames."
|
f"Not saving preview for {self.config.name} because there are no saved frames."
|
||||||
)
|
)
|
||||||
|
|
||||||
# reset frame cache
|
self.reset_frame_cache(frame_time)
|
||||||
self.segment_end = (
|
|
||||||
(datetime.datetime.now() + datetime.timedelta(hours=1))
|
|
||||||
.astimezone(datetime.timezone.utc)
|
|
||||||
.replace(minute=0, second=0, microsecond=0)
|
|
||||||
.timestamp()
|
|
||||||
)
|
|
||||||
self.start_time = frame_time
|
|
||||||
self.last_output_time = frame_time
|
|
||||||
self.output_frames: list[float] = []
|
|
||||||
|
|
||||||
# include first frame to ensure consistent duration
|
# include first frame to ensure consistent duration
|
||||||
if self.config.record.enabled:
|
if self.config.record.enabled:
|
||||||
self.output_frames.append(frame_time)
|
self.output_frames.append(frame_time)
|
||||||
self.write_frame_to_cache(frame_time, frame)
|
self.write_frame_to_cache(frame_time, frame)
|
||||||
|
|
||||||
return True
|
return
|
||||||
elif self.should_write_frame(current_tracked_objects, motion_boxes, frame_time):
|
elif self.should_write_frame(current_tracked_objects, motion_boxes, frame_time):
|
||||||
self.output_frames.append(frame_time)
|
self.output_frames.append(frame_time)
|
||||||
self.write_frame_to_cache(frame_time, frame)
|
self.write_frame_to_cache(frame_time, frame)
|
||||||
return False
|
return
|
||||||
|
|
||||||
def flag_offline(self, frame_time: float) -> None:
|
def flag_offline(self, frame_time: float) -> None:
|
||||||
|
if not self.offline:
|
||||||
|
self.write_frame_to_cache(
|
||||||
|
frame_time,
|
||||||
|
get_blank_yuv_frame(
|
||||||
|
self.config.detect.width, self.config.detect.height
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.offline = True
|
||||||
|
|
||||||
# check if PREVIEW clip should be generated and cached frames reset
|
# check if PREVIEW clip should be generated and cached frames reset
|
||||||
if frame_time >= self.segment_end:
|
if frame_time >= self.segment_end:
|
||||||
if len(self.output_frames) == 0:
|
if len(self.output_frames) == 0:
|
||||||
|
# camera has been offline for entire hour
|
||||||
|
# we have no preview to create
|
||||||
|
self.reset_frame_cache(frame_time)
|
||||||
return
|
return
|
||||||
|
|
||||||
old_frame_path = get_cache_image_name(
|
old_frame_path = get_cache_image_name(
|
||||||
@ -382,16 +399,7 @@ class PreviewRecorder:
|
|||||||
self.requestor,
|
self.requestor,
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
# reset frame cache
|
self.reset_frame_cache(frame_time)
|
||||||
self.segment_end = (
|
|
||||||
(datetime.datetime.now() + datetime.timedelta(hours=1))
|
|
||||||
.astimezone(datetime.timezone.utc)
|
|
||||||
.replace(minute=0, second=0, microsecond=0)
|
|
||||||
.timestamp()
|
|
||||||
)
|
|
||||||
self.start_time = frame_time
|
|
||||||
self.last_output_time = frame_time
|
|
||||||
self.output_frames = []
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.requestor.stop()
|
self.requestor.stop()
|
||||||
|
|||||||
@ -150,6 +150,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
self.requestor = InterProcessRequestor()
|
self.requestor = InterProcessRequestor()
|
||||||
self.record_config_subscriber = ConfigSubscriber("config/record/")
|
self.record_config_subscriber = ConfigSubscriber("config/record/")
|
||||||
self.review_config_subscriber = ConfigSubscriber("config/review/")
|
self.review_config_subscriber = ConfigSubscriber("config/review/")
|
||||||
|
self.enabled_config_subscriber = ConfigSubscriber("config/enabled/")
|
||||||
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all)
|
self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all)
|
||||||
|
|
||||||
# manual events
|
# manual events
|
||||||
@ -450,7 +451,16 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
updated_review_config,
|
updated_review_config,
|
||||||
) = self.review_config_subscriber.check_for_update()
|
) = self.review_config_subscriber.check_for_update()
|
||||||
|
|
||||||
if not updated_record_topic and not updated_review_topic:
|
(
|
||||||
|
updated_enabled_topic,
|
||||||
|
updated_enabled_config,
|
||||||
|
) = self.enabled_config_subscriber.check_for_update()
|
||||||
|
|
||||||
|
if (
|
||||||
|
not updated_record_topic
|
||||||
|
and not updated_review_topic
|
||||||
|
and not updated_enabled_topic
|
||||||
|
):
|
||||||
break
|
break
|
||||||
|
|
||||||
if updated_record_topic:
|
if updated_record_topic:
|
||||||
@ -461,6 +471,12 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
camera_name = updated_review_topic.rpartition("/")[-1]
|
camera_name = updated_review_topic.rpartition("/")[-1]
|
||||||
self.config.cameras[camera_name].review = updated_review_config
|
self.config.cameras[camera_name].review = updated_review_config
|
||||||
|
|
||||||
|
if updated_enabled_config:
|
||||||
|
camera_name = updated_enabled_topic.rpartition("/")[-1]
|
||||||
|
self.config.cameras[
|
||||||
|
camera_name
|
||||||
|
].enabled = updated_enabled_config.enabled
|
||||||
|
|
||||||
(topic, data) = self.detection_subscriber.check_for_update(timeout=1)
|
(topic, data) = self.detection_subscriber.check_for_update(timeout=1)
|
||||||
|
|
||||||
if not topic:
|
if not topic:
|
||||||
@ -494,7 +510,10 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
|
|
||||||
current_segment = self.active_review_segments.get(camera)
|
current_segment = self.active_review_segments.get(camera)
|
||||||
|
|
||||||
if not self.config.cameras[camera].record.enabled:
|
if (
|
||||||
|
not self.config.cameras[camera].enabled
|
||||||
|
or not self.config.cameras[camera].record.enabled
|
||||||
|
):
|
||||||
if current_segment:
|
if current_segment:
|
||||||
self.end_segment(camera)
|
self.end_segment(camera)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -300,6 +300,12 @@ def migrate_016_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]
|
|||||||
"""Handle migrating frigate config to 0.16-0"""
|
"""Handle migrating frigate config to 0.16-0"""
|
||||||
new_config = config.copy()
|
new_config = config.copy()
|
||||||
|
|
||||||
|
# migrate config that does not have detect -> enabled explicitly set to have it enabled
|
||||||
|
if new_config.get("detect", {}).get("enabled") is None:
|
||||||
|
detect_config = new_config.get("detect", {})
|
||||||
|
detect_config["enabled"] = True
|
||||||
|
new_config["detect"] = detect_config
|
||||||
|
|
||||||
for name, camera in config.get("cameras", {}).items():
|
for name, camera in config.get("cameras", {}).items():
|
||||||
camera_config: dict[str, dict[str, any]] = camera.copy()
|
camera_config: dict[str, dict[str, any]] = camera.copy()
|
||||||
|
|
||||||
|
|||||||
@ -975,3 +975,22 @@ def get_histogram(image, x_min, y_min, x_max, y_max):
|
|||||||
[image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256]
|
[image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256]
|
||||||
)
|
)
|
||||||
return cv2.normalize(hist, hist).flatten()
|
return cv2.normalize(hist, hist).flatten()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_jpeg_bytes(image_data):
|
||||||
|
"""Ensure image data is jpeg bytes for genai"""
|
||||||
|
try:
|
||||||
|
img_array = np.frombuffer(image_data, dtype=np.uint8)
|
||||||
|
img = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
|
||||||
|
|
||||||
|
if img is None:
|
||||||
|
return image_data
|
||||||
|
|
||||||
|
success, encoded_img = cv2.imencode(".jpg", img)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return encoded_img.tobytes()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error when converting thumbnail to jpeg for genai: {e}")
|
||||||
|
|
||||||
|
return image_data
|
||||||
|
|||||||
@ -362,7 +362,7 @@ def get_intel_gpu_stats(sriov: bool) -> dict[str, str]:
|
|||||||
if video_frame is not None:
|
if video_frame is not None:
|
||||||
video[key].append(float(video_frame))
|
video[key].append(float(video_frame))
|
||||||
|
|
||||||
if render["global"]:
|
if render["global"] and video["global"]:
|
||||||
results["gpu"] = (
|
results["gpu"] = (
|
||||||
f"{round(((sum(render['global']) / len(render['global'])) + (sum(video['global']) / len(video['global']))) / 2, 2)}%"
|
f"{round(((sum(render['global']) / len(render['global'])) + (sum(video['global']) / len(video['global']))) / 2, 2)}%"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -197,9 +197,10 @@ class CameraWatchdog(threading.Thread):
|
|||||||
"""Fetch the latest config and update enabled state."""
|
"""Fetch the latest config and update enabled state."""
|
||||||
_, config_data = self.config_subscriber.check_for_update()
|
_, config_data = self.config_subscriber.check_for_update()
|
||||||
if config_data:
|
if config_data:
|
||||||
enabled = config_data.enabled
|
self.config.enabled = config_data.enabled
|
||||||
return enabled
|
return config_data.enabled
|
||||||
return self.was_enabled if self.was_enabled is not None else self.config.enabled
|
|
||||||
|
return self.config.enabled
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
if self._update_enabled_state():
|
if self._update_enabled_state():
|
||||||
|
|||||||
2442
web/package-lock.json
generated
2442
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,47 +14,47 @@
|
|||||||
"coverage": "vitest run --coverage"
|
"coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^6.1.1",
|
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@melloware/react-logviewer": "^6.1.2",
|
"@melloware/react-logviewer": "^6.1.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||||
"@radix-ui/react-checkbox": "^1.1.2",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-context-menu": "^2.2.2",
|
"@radix-ui/react-context-menu": "^2.2.6",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||||
"@radix-ui/react-hover-card": "^1.1.2",
|
"@radix-ui/react-hover-card": "^1.1.6",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-popover": "^1.1.2",
|
"@radix-ui/react-popover": "^1.1.6",
|
||||||
"@radix-ui/react-radio-group": "^1.2.1",
|
"@radix-ui/react-radio-group": "^1.2.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
"@radix-ui/react-slider": "^1.2.1",
|
"@radix-ui/react-slider": "^1.2.3",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-switch": "^1.1.1",
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@radix-ui/react-toggle": "^1.1.0",
|
"@radix-ui/react-toggle": "^1.1.2",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"apexcharts": "^3.52.0",
|
"apexcharts": "^3.52.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.2.0",
|
"embla-carousel-react": "^8.2.0",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.4",
|
||||||
"hls.js": "^1.5.17",
|
"hls.js": "^1.5.20",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-http-backend": "^3.0.1",
|
"i18next-http-backend": "^3.0.1",
|
||||||
"idb-keyval": "^6.2.1",
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"konva": "^9.3.16",
|
"konva": "^9.3.18",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.407.0",
|
"lucide-react": "^0.477.0",
|
||||||
"monaco-yaml": "^5.2.2",
|
"monaco-yaml": "^5.3.1",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"nosleep.js": "^0.12.0",
|
"nosleep.js": "^0.12.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@ -65,10 +65,10 @@
|
|||||||
"react-grid-layout": "^1.5.0",
|
"react-grid-layout": "^1.5.0",
|
||||||
"react-hook-form": "^7.52.1",
|
"react-hook-form": "^7.52.1",
|
||||||
"react-i18next": "^15.2.0",
|
"react-i18next": "^15.2.0",
|
||||||
"react-icons": "^5.2.1",
|
"react-icons": "^5.5.0",
|
||||||
"react-konva": "^18.2.10",
|
"react-konva": "^18.2.10",
|
||||||
"react-router-dom": "^6.26.0",
|
"react-router-dom": "^6.26.0",
|
||||||
"react-swipeable": "^7.0.1",
|
"react-swipeable": "^7.0.2",
|
||||||
"react-tracked": "^2.0.1",
|
"react-tracked": "^2.0.1",
|
||||||
"react-transition-group": "^4.4.5",
|
"react-transition-group": "^4.4.5",
|
||||||
"react-use-websocket": "^4.8.1",
|
"react-use-websocket": "^4.8.1",
|
||||||
@ -78,7 +78,7 @@
|
|||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
"sort-by": "^1.2.0",
|
"sort-by": "^1.2.0",
|
||||||
"strftime": "^0.10.3",
|
"strftime": "^0.10.3",
|
||||||
"swr": "^2.2.5",
|
"swr": "^2.3.2",
|
||||||
"tailwind-merge": "^2.4.0",
|
"tailwind-merge": "^2.4.0",
|
||||||
"tailwind-scrollbar": "^3.1.0",
|
"tailwind-scrollbar": "^3.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@ -100,8 +100,8 @@
|
|||||||
"@types/strftime": "^0.9.8",
|
"@types/strftime": "^0.9.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||||
"@typescript-eslint/parser": "^7.5.0",
|
"@typescript-eslint/parser": "^7.5.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.1",
|
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||||
"@vitest/coverage-v8": "^2.0.5",
|
"@vitest/coverage-v8": "^3.0.7",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
@ -118,8 +118,8 @@
|
|||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||||
"tailwindcss": "^3.4.9",
|
"tailwindcss": "^3.4.9",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^5.4.0",
|
"vite": "^6.2.0",
|
||||||
"vitest": "^2.0.5"
|
"vitest": "^3.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ type CameraFilterButtonProps = {
|
|||||||
groups: [string, CameraGroupConfig][];
|
groups: [string, CameraGroupConfig][];
|
||||||
selectedCameras: string[] | undefined;
|
selectedCameras: string[] | undefined;
|
||||||
hideText?: boolean;
|
hideText?: boolean;
|
||||||
|
mainCamera?: string;
|
||||||
updateCameraFilter: (cameras: string[] | undefined) => void;
|
updateCameraFilter: (cameras: string[] | undefined) => void;
|
||||||
};
|
};
|
||||||
export function CamerasFilterButton({
|
export function CamerasFilterButton({
|
||||||
@ -27,6 +28,7 @@ export function CamerasFilterButton({
|
|||||||
groups,
|
groups,
|
||||||
selectedCameras,
|
selectedCameras,
|
||||||
hideText = isMobile,
|
hideText = isMobile,
|
||||||
|
mainCamera,
|
||||||
updateCameraFilter,
|
updateCameraFilter,
|
||||||
}: CameraFilterButtonProps) {
|
}: CameraFilterButtonProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -76,6 +78,7 @@ export function CamerasFilterButton({
|
|||||||
allCameras={allCameras}
|
allCameras={allCameras}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
currentCameras={currentCameras}
|
currentCameras={currentCameras}
|
||||||
|
mainCamera={mainCamera}
|
||||||
setCurrentCameras={setCurrentCameras}
|
setCurrentCameras={setCurrentCameras}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
updateCameraFilter={updateCameraFilter}
|
updateCameraFilter={updateCameraFilter}
|
||||||
@ -122,6 +125,7 @@ export function CamerasFilterButton({
|
|||||||
type CamerasFilterContentProps = {
|
type CamerasFilterContentProps = {
|
||||||
allCameras: string[];
|
allCameras: string[];
|
||||||
currentCameras: string[] | undefined;
|
currentCameras: string[] | undefined;
|
||||||
|
mainCamera?: string;
|
||||||
groups: [string, CameraGroupConfig][];
|
groups: [string, CameraGroupConfig][];
|
||||||
setCurrentCameras: (cameras: string[] | undefined) => void;
|
setCurrentCameras: (cameras: string[] | undefined) => void;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
@ -130,6 +134,7 @@ type CamerasFilterContentProps = {
|
|||||||
export function CamerasFilterContent({
|
export function CamerasFilterContent({
|
||||||
allCameras,
|
allCameras,
|
||||||
currentCameras,
|
currentCameras,
|
||||||
|
mainCamera,
|
||||||
groups,
|
groups,
|
||||||
setCurrentCameras,
|
setCurrentCameras,
|
||||||
setOpen,
|
setOpen,
|
||||||
@ -180,12 +185,29 @@ export function CamerasFilterContent({
|
|||||||
key={item}
|
key={item}
|
||||||
isChecked={currentCameras?.includes(item) ?? false}
|
isChecked={currentCameras?.includes(item) ?? false}
|
||||||
label={item.replaceAll("_", " ")}
|
label={item.replaceAll("_", " ")}
|
||||||
|
disabled={
|
||||||
|
mainCamera !== undefined &&
|
||||||
|
currentCameras !== undefined &&
|
||||||
|
item === mainCamera
|
||||||
|
} // Disable only if mainCamera exists and cameras are filtered
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
|
if (
|
||||||
|
mainCamera !== undefined && // Only enforce if mainCamera is defined
|
||||||
|
item === mainCamera &&
|
||||||
|
!isChecked &&
|
||||||
|
currentCameras !== undefined
|
||||||
|
) {
|
||||||
|
return; // Prevent deselecting mainCamera when filtered and mainCamera is defined
|
||||||
|
}
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
const updatedCameras = currentCameras
|
const updatedCameras = currentCameras
|
||||||
? [...currentCameras]
|
? [...currentCameras]
|
||||||
: [];
|
: mainCamera !== undefined && item !== mainCamera // If mainCamera exists and this isn’t it
|
||||||
|
? [mainCamera] // Start with mainCamera when transitioning from undefined
|
||||||
|
: []; // Otherwise start empty
|
||||||
|
if (!updatedCameras.includes(item)) {
|
||||||
updatedCameras.push(item);
|
updatedCameras.push(item);
|
||||||
|
}
|
||||||
setCurrentCameras(updatedCameras);
|
setCurrentCameras(updatedCameras);
|
||||||
} else {
|
} else {
|
||||||
const updatedCameras = currentCameras
|
const updatedCameras = currentCameras
|
||||||
|
|||||||
@ -51,6 +51,7 @@ type ReviewFilterGroupProps = {
|
|||||||
motionOnly: boolean;
|
motionOnly: boolean;
|
||||||
filterList?: FilterList;
|
filterList?: FilterList;
|
||||||
showReviewed: boolean;
|
showReviewed: boolean;
|
||||||
|
mainCamera?: string;
|
||||||
setShowReviewed: (show: boolean) => void;
|
setShowReviewed: (show: boolean) => void;
|
||||||
onUpdateFilter: (filter: ReviewFilter) => void;
|
onUpdateFilter: (filter: ReviewFilter) => void;
|
||||||
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
|
setMotionOnly: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@ -65,6 +66,7 @@ export default function ReviewFilterGroup({
|
|||||||
motionOnly,
|
motionOnly,
|
||||||
filterList,
|
filterList,
|
||||||
showReviewed,
|
showReviewed,
|
||||||
|
mainCamera,
|
||||||
setShowReviewed,
|
setShowReviewed,
|
||||||
onUpdateFilter,
|
onUpdateFilter,
|
||||||
setMotionOnly,
|
setMotionOnly,
|
||||||
@ -187,6 +189,7 @@ export default function ReviewFilterGroup({
|
|||||||
allCameras={filterValues.cameras}
|
allCameras={filterValues.cameras}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
selectedCameras={filter?.cameras}
|
selectedCameras={filter?.cameras}
|
||||||
|
mainCamera={mainCamera}
|
||||||
updateCameraFilter={(newCameras) => {
|
updateCameraFilter={(newCameras) => {
|
||||||
onUpdateFilter({ ...filter, cameras: newCameras });
|
onUpdateFilter({ ...filter, cameras: newCameras });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -16,9 +16,9 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { getUnitSize } from "@/utils/storageUtil";
|
import { getUnitSize } from "@/utils/storageUtil";
|
||||||
import { LuAlertCircle } from "react-icons/lu";
|
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
|
import { CiCircleAlert } from "react-icons/ci";
|
||||||
|
|
||||||
type CameraStorage = {
|
type CameraStorage = {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
@ -213,7 +213,7 @@ export function CombinedStorageGraph({
|
|||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
aria-label="Unused Storage Information"
|
aria-label="Unused Storage Information"
|
||||||
>
|
>
|
||||||
<LuAlertCircle
|
<CiCircleAlert
|
||||||
className="size-5"
|
className="size-5"
|
||||||
aria-label="Unused Storage Information"
|
aria-label="Unused Storage Information"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LuLoader2 } from "react-icons/lu";
|
import { AiOutlineLoading3Quarters } from "react-icons/ai";
|
||||||
|
|
||||||
export default function ActivityIndicator({ className = "w-full", size = 30 }) {
|
export default function ActivityIndicator({ className = "w-full", size = 30 }) {
|
||||||
return (
|
return (
|
||||||
@ -7,7 +7,7 @@ export default function ActivityIndicator({ className = "w-full", size = 30 }) {
|
|||||||
className={cn("flex items-center justify-center", className)}
|
className={cn("flex items-center justify-center", className)}
|
||||||
aria-label="Loading…"
|
aria-label="Loading…"
|
||||||
>
|
>
|
||||||
<LuLoader2 className="animate-spin" size={size} />
|
<AiOutlineLoading3Quarters className="animate-spin" size={size} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
LuList,
|
LuList,
|
||||||
LuLogOut,
|
LuLogOut,
|
||||||
LuMoon,
|
LuMoon,
|
||||||
LuPenSquare,
|
LuSquarePen,
|
||||||
LuRotateCw,
|
LuRotateCw,
|
||||||
LuSettings,
|
LuSettings,
|
||||||
LuSun,
|
LuSun,
|
||||||
@ -215,7 +215,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
}
|
}
|
||||||
aria-label="Configuration editor"
|
aria-label="Configuration editor"
|
||||||
>
|
>
|
||||||
<LuPenSquare className="mr-2 size-4" />
|
<LuSquarePen className="mr-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>ui.configurationEditor</Trans>
|
<Trans>ui.configurationEditor</Trans>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu";
|
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
|
||||||
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import { FaArrowsRotate } from "react-icons/fa6";
|
import { FaArrowsRotate } from "react-icons/fa6";
|
||||||
import { MdImageSearch } from "react-icons/md";
|
import { MdImageSearch } from "react-icons/md";
|
||||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
||||||
@ -233,7 +234,7 @@ export default function SearchResultActions({
|
|||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
<LuMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
|
<FiMoreVertical className="size-5 cursor-pointer text-primary-variant hover:text-primary" />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">{menuItems}</DropdownMenuContent>
|
<DropdownMenuContent align="end">{menuItems}</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import {
|
|||||||
LuEar,
|
LuEar,
|
||||||
LuFolderX,
|
LuFolderX,
|
||||||
LuPlay,
|
LuPlay,
|
||||||
LuPlayCircle,
|
|
||||||
LuSettings,
|
LuSettings,
|
||||||
LuTruck,
|
LuTruck,
|
||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
@ -54,6 +53,7 @@ import {
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ObjectPath } from "./ObjectPath";
|
import { ObjectPath } from "./ObjectPath";
|
||||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
|
import { IoPlayCircleOutline } from "react-icons/io5";
|
||||||
|
|
||||||
type ObjectLifecycleProps = {
|
type ObjectLifecycleProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -733,7 +733,7 @@ export function LifecycleIcon({
|
|||||||
case "gone":
|
case "gone":
|
||||||
return <IoMdExit className={cn(className)} />;
|
return <IoMdExit className={cn(className)} />;
|
||||||
case "active":
|
case "active":
|
||||||
return <LuPlayCircle className={cn(className)} />;
|
return <IoPlayCircleOutline className={cn(className)} />;
|
||||||
case "stationary":
|
case "stationary":
|
||||||
return <LuCircle className={cn(className)} />;
|
return <LuCircle className={cn(className)} />;
|
||||||
case "entered_zone":
|
case "entered_zone":
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LuArrowRightCircle } from "react-icons/lu";
|
import { BsArrowRightCircle } from "react-icons/bs";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -183,7 +183,7 @@ function ThumbnailRow({
|
|||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<LuArrowRightCircle
|
<BsArrowRightCircle
|
||||||
className="ml-2 text-secondary-foreground transition-all duration-300 hover:text-primary"
|
className="ml-2 text-secondary-foreground transition-all duration-300 hover:text-primary"
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -364,6 +364,28 @@ export default function LiveCameraView({
|
|||||||
}
|
}
|
||||||
}, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]);
|
}, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]);
|
||||||
|
|
||||||
|
// On mobile devices that support it, try to orient screen
|
||||||
|
// to best fit the camera feed in fullscreen mode
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const screenOrientation = screen.orientation as any;
|
||||||
|
if (!screenOrientation.lock || !screenOrientation.unlock) {
|
||||||
|
// Browser does not support ScreenOrientation APIs that we need
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreen) {
|
||||||
|
const orientationForBestFit =
|
||||||
|
cameraAspectRatio > 1 ? "landscape" : "portrait";
|
||||||
|
|
||||||
|
// If the current device doesn't support locking orientation,
|
||||||
|
// this promise will reject with an error that we can ignore
|
||||||
|
screenOrientation.lock(orientationForBestFit).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => screenOrientation.unlock();
|
||||||
|
}, [fullscreen, cameraAspectRatio]);
|
||||||
|
|
||||||
const handleError = useCallback(
|
const handleError = useCallback(
|
||||||
(e: LivePlayerError) => {
|
(e: LivePlayerError) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
|
|||||||
@ -451,7 +451,7 @@ export function RecordingView({
|
|||||||
)}
|
)}
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
<ReviewFilterGroup
|
<ReviewFilterGroup
|
||||||
filters={["date", "general"]}
|
filters={["cameras", "date", "general"]}
|
||||||
reviewSummary={reviewSummary}
|
reviewSummary={reviewSummary}
|
||||||
recordingsSummary={recordingsSummary}
|
recordingsSummary={recordingsSummary}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
@ -459,7 +459,22 @@ export function RecordingView({
|
|||||||
filterList={reviewFilterList}
|
filterList={reviewFilterList}
|
||||||
showReviewed
|
showReviewed
|
||||||
setShowReviewed={() => {}}
|
setShowReviewed={() => {}}
|
||||||
onUpdateFilter={updateFilter}
|
mainCamera={mainCamera}
|
||||||
|
onUpdateFilter={(newFilter: ReviewFilter) => {
|
||||||
|
const updatedCameras =
|
||||||
|
newFilter.cameras === undefined
|
||||||
|
? undefined // Respect undefined as "all cameras"
|
||||||
|
: newFilter.cameras
|
||||||
|
? Array.from(
|
||||||
|
new Set([mainCamera, ...(newFilter.cameras || [])]),
|
||||||
|
) // Include mainCamera if specific cameras are selected
|
||||||
|
: [mainCamera];
|
||||||
|
const adjustedFilter: ReviewFilter = {
|
||||||
|
...newFilter,
|
||||||
|
cameras: updatedCameras,
|
||||||
|
};
|
||||||
|
updateFilter(adjustedFilter);
|
||||||
|
}}
|
||||||
setMotionOnly={() => {}}
|
setMotionOnly={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -22,7 +22,8 @@ import { t } from "i18next";
|
|||||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
import { LuAlertCircle, LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||||
|
import { CiCircleAlert } from "react-icons/ci";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -313,7 +314,7 @@ export default function NotificationView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<LuAlertCircle className="size-5" />
|
<CiCircleAlert className="size-5" />
|
||||||
<AlertTitle>Notifications Unavailable</AlertTitle>
|
<AlertTitle>Notifications Unavailable</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
|
|||||||
@ -8,8 +8,8 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { LuAlertCircle } from "react-icons/lu";
|
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
|
import { CiCircleAlert } from "react-icons/ci";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { useTimezone } from "@/hooks/use-date-utils";
|
import { useTimezone } from "@/hooks/use-date-utils";
|
||||||
import { RecordingsSummary } from "@/types/review";
|
import { RecordingsSummary } from "@/types/review";
|
||||||
@ -89,7 +89,7 @@ export default function StorageMetrics({
|
|||||||
className="focus:outline-none"
|
className="focus:outline-none"
|
||||||
aria-label="Unused Storage Information"
|
aria-label="Unused Storage Information"
|
||||||
>
|
>
|
||||||
<LuAlertCircle
|
<CiCircleAlert
|
||||||
className="size-5"
|
className="size-5"
|
||||||
aria-label="Unused Storage Information"
|
aria-label="Unused Storage Information"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user