Merge branch 'dev' into http-api-documentation

# Conflicts:
#	docs/docs/integrations/api.md
#	docs/sidebars.js
This commit is contained in:
Rui Alves 2024-10-06 17:39:44 +01:00
commit e6a2db617e
81 changed files with 1077 additions and 316 deletions

View File

@ -205,7 +205,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@ -16,7 +16,7 @@ jobs:
with:
string: ${{ github.repository }}
- name: Log in to the Container registry
uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@ -13,6 +13,7 @@ apt-get -qq install --no-install-recommends -y \
python3.9 \
python3-pip \
curl \
lsof \
jq \
nethogs
@ -44,7 +45,7 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
wget -qO btbn-ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linux64-gpl-5.1.tar.xz"
tar -xf btbn-ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1
rm -rf btbn-ffmpeg.tar.xz /usr/lib/ffmpeg/5.0/doc /usr/lib/ffmpeg/5.0/bin/ffplay
wget -qO btbn-ffmpeg.tar.xz "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
wget -qO btbn-ffmpeg.tar.xz "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2024-09-30-15-36/ffmpeg-n7.1-linux64-gpl-7.1.tar.xz"
tar -xf btbn-ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1
rm -rf btbn-ffmpeg.tar.xz /usr/lib/ffmpeg/7.0/doc /usr/lib/ffmpeg/7.0/bin/ffplay
fi
@ -56,7 +57,7 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
wget -qO btbn-ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linuxarm64-gpl-5.1.tar.xz"
tar -xf btbn-ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1
rm -rf btbn-ffmpeg.tar.xz /usr/lib/ffmpeg/5.0/doc /usr/lib/ffmpeg/5.0/bin/ffplay
wget -qO btbn-ffmpeg.tar.xz "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
wget -qO btbn-ffmpeg.tar.xz "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2024-09-30-15-36/ffmpeg-n7.1-linuxarm64-gpl-7.1.tar.xz"
tar -xf btbn-ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1
rm -rf btbn-ffmpeg.tar.xz /usr/lib/ffmpeg/7.0/doc /usr/lib/ffmpeg/7.0/bin/ffplay
fi

View File

@ -83,6 +83,7 @@ ARG AMDGPU
COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*$AMDGPU* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx908* /opt/rocm-$ROCM/share/miopen/db/
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*$AMDGPU* /opt/rocm-$ROCM/lib/rocblas/library/
COPY --from=rocm /opt/rocm-dist/ /
COPY --from=debian-build /opt/rocm/lib/migraphx.cpython-39-x86_64-linux-gnu.so /opt/rocm-$ROCM/lib/

View File

@ -183,7 +183,7 @@ To do this:
3. Give `go2rtc` execute permission.
4. Restart Frigate and the custom version will be used, you can verify by checking go2rtc logs.
## Validating your config.yaml file updates
## Validating your config.yml file updates
When frigate starts up, it checks whether your config file is valid, and if it is not, the process exits. To minimize interruptions when updating your config, you have three options -- you can edit the config via the WebUI which has built in validation, use the config API, or you can validate on the command line using the frigate docker container.
@ -211,5 +211,5 @@ docker run \
--entrypoint python3 \
ghcr.io/blakeblackshear/frigate:stable \
-u -m frigate \
--validate_config
--validate-config
```

View File

@ -9,6 +9,12 @@ This page makes use of presets of FFmpeg args. For more information on presets,
:::
:::note
Many cameras support encoding options which greatly affect the live view experience, see the [Live view](/configuration/live) page for more info.
:::
## MJPEG Cameras
Note that mjpeg cameras require encoding the video into h264 for recording, and restream roles. This will use significantly more CPU than if the cameras supported h264 feeds directly. It is recommended to use the restream role to create an h264 restream and then use that as the source for ffmpeg.

View File

@ -79,29 +79,41 @@ cameras:
If the ONVIF connection is successful, PTZ controls will be available in the camera's WebUI.
:::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: ""`.
:::
An ONVIF-capable camera that supports relative movement within the field of view (FOV) can also be configured to automatically track moving objects and keep them in the center of the frame. For autotracking setup, see the [autotracking](autotracking.md) docs.
## ONVIF PTZ camera recommendations
This list of working and non-working PTZ cameras is based on user feedback.
| 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 ASH21 | ❌ | ❌ | No ONVIF support |
| Ctronics PTZ | ✅ | ❌ | |
| Dahua | ✅ | ✅ | |
| Foscam R5 | ✅ | ❌ | |
| Hanwha XNP-6550RH | ✅ | ❌ | |
| Hikvision | ✅ | ❌ | Incomplete ONVIF support (MoveStatus won't update even on latest firmware) - reported with HWP-N4215IH-DE and DS-2DE3304W-DE, but likely others |
| Reolink 511WA | ✅ | ❌ | Zoom only |
| Reolink E1 Pro | ✅ | ❌ | |
| Reolink E1 Zoom | ✅ | ❌ | |
| Reolink RLC-823A 16x | ✅ | ❌ | |
| Sunba 405-D20X | ✅ | ❌ | |
| Tapo | ✅ | ❌ | Many models supported, ONVIF Service Port: 2020 |
| Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands |
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |
| 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 ASH21 | ✅ | ❌ | ONVIF service port: 80 |
| Amcrest IP4M-S2112EW-AI | ✅ | ❌ | FOV relative movement not supported. |
| Amcrest IP5M-1190EW | ✅ | ❌ | ONVIF Port: 80. FOV relative movement not supported. |
| Ctronics PTZ | ✅ | ❌ | |
| Dahua | ✅ | ✅ | |
| Dahua DH-SD2A500HB | ✅ | ❌ | |
| Foscam R5 | ✅ | ❌ | |
| Hanwha XNP-6550RH | ✅ | ❌ | |
| Hikvision | ✅ | ❌ | Incomplete ONVIF support (MoveStatus won't update even on latest firmware) - reported with HWP-N4215IH-DE and DS-2DE3304W-DE, but likely others |
| Hikvision DS-2DE3A404IWG-E/W | ✅ | ✅ | |
| Reolink 511WA | ✅ | ❌ | Zoom only |
| Reolink E1 Pro | ✅ | ❌ | |
| Reolink E1 Zoom | ✅ | ❌ | |
| Reolink RLC-823A 16x | ✅ | ❌ | |
| Speco O8P32X | ✅ | ❌ | |
| Sunba 405-D20X | ✅ | ❌ | |
| Tapo | ✅ | ❌ | Many models supported, ONVIF Service Port: 2020 |
| Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands |
| Uniview IPC6612SR-X33-VG | ✅ | ✅ | Leave `calibrate_on_startup` as `False`. A user has reported that zooming with `absolute` is working. |
| Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support |
## Setting up camera groups

View File

@ -100,6 +100,28 @@ genai:
model: gpt-4o
```
## Azure OpenAI
Microsoft offers several vision models through Azure OpenAI. A subscription is required.
### Supported Models
You must use a vision capable model with Frigate. Current model variants can be found [in their documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models). At the time of writing, this includes `gpt-4o` and `gpt-4-turbo`.
### Create Resource and Get API Key
To start using Azure OpenAI, you must first [create a resource](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource). You'll need your API key and resource URL, which must include the `api-version` parameter (see the example below). The model field is not required in your configuration as the model is part of the deployment name you chose when deploying the resource.
### Configuration
```yaml
genai:
enabled: True
provider: azure_openai
base_url: https://example-endpoint.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2023-03-15-preview
api_key: "{FRIGATE_OPENAI_API_KEY}"
```
## 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:
@ -130,10 +152,13 @@ genai:
Prompts can also be overriden at the camera level to provide a more detailed prompt to the model about your specific camera, if you desire. By default, descriptions will be generated for all tracked objects and all zones. But you can also optionally specify `objects` and `required_zones` to only generate descriptions for certain tracked objects or zones.
Optionally, you can generate the description using a snapshot (if enabled) by setting `use_snapshot` to `True`. By default, this is set to `False`, which sends the thumbnails collected over the object's lifetime to the model. Using a snapshot provides the AI with a higher-resolution image (typically downscaled by the AI itself), but the trade-off is that only a single image is used, which might limit the model's ability to determine object movement or direction.
```yaml
cameras:
front_door:
genai:
use_snapshot: True
prompt: "Describe the {label} in these images from the {camera} security camera at the front door of a house, aimed outward toward the street."
object_prompts:
person: "Describe the main person in these images (gender, age, clothing, activity, etc). Do not include where the activity is occurring (sidewalk, concrete, driveway, etc). If delivering a package, include the company the package is from."

View File

@ -65,6 +65,8 @@ Or map in all the `/dev/video*` devices.
## Intel-based CPUs
:::info
**Recommended hwaccel Preset**
| CPU Generation | Intel Driver | Recommended Preset | Notes |
@ -74,11 +76,13 @@ Or map in all the `/dev/video*` devices.
| gen13+ | iHD / Xe | preset-intel-qsv-* | |
| Intel Arc GPU | iHD / Xe | preset-intel-qsv-* | |
:::
:::note
The default driver is `iHD`. You may need to change the driver to `i965` by adding the following environment variable `LIBVA_DRIVER_NAME=i965` to your docker-compose file or [in the `frigate.yaml` for HA OS users](advanced.md#environment_vars).
See [The Intel Docs](https://www.intel.com/content/www/us/en/support/articles/000005505/processors.html to figure out what generation your CPU is.)
See [The Intel Docs](https://www.intel.com/content/www/us/en/support/articles/000005505/processors.html) to figure out what generation your CPU is.
:::
@ -379,7 +383,7 @@ Make sure to follow the [Rockchip specific installation instructions](/frigate/i
### Configuration
Add one of the following FFmpeg presets to your `config.yaml` to enable hardware video processing:
Add one of the following FFmpeg presets to your `config.yml` to enable hardware video processing:
```yaml
# if you try to decode a h264 encoded stream

View File

@ -11,11 +11,21 @@ Frigate intelligently uses three different streaming technologies to display you
The jsmpeg live view will use more browser and client GPU resources. Using go2rtc is highly recommended and will provide a superior experience.
| Source | Latency | Frame Rate | Resolution | Audio | Requires go2rtc | Other Limitations |
| ------ | ------- | ------------------------------------- | ---------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------------ |
| jsmpeg | low | same as `detect -> fps`, capped at 10 | 720p | no | no | resolution is configurable, but go2rtc is recommended if you want higher resolutions |
| mse | low | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only |
| webrtc | lowest | native | native | yes (depends on audio codec) | yes | requires extra config, doesn't support h.265 |
| Source | Frame Rate | Resolution | Audio | Requires go2rtc | Notes |
| ------ | ------------------------------------- | ---------- | ---------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| jsmpeg | same as `detect -> fps`, capped at 10 | 720p | no | no | Resolution is configurable, but go2rtc is recommended if you want higher resolutions and better frame rates. jsmpeg is Frigate's default without go2rtc configured. |
| mse | native | native | yes (depends on audio codec) | yes | iPhone requires iOS 17.1+, Firefox is h.264 only. This is Frigate's default when go2rtc is configured. |
| webrtc | native | native | yes (depends on audio codec) | yes | Requires extra configuration, doesn't support h.265. Frigate attempts to use WebRTC when MSE fails or when using a camera's two-way talk feature. |
### Camera Settings Recommendations
If you are using go2rtc, you should adjust the following settings in your camera's firmware for the best experience with Live view:
- Video codec: **H.264** - provides the most compatible video codec with all Live view technologies and browsers. Avoid any kind of "smart codec" or "+" codec like _H.264+_ or _H.265+_. as these non-standard codecs remove keyframes (see below).
- Audio codec: **AAC** - provides the most compatible audio codec with all Live view technologies and browsers that support audio.
- I-frame interval (sometimes called the keyframe interval, the interframe space, or the GOP length): match your camera's frame rate, or choose "1x" (for interframe space on Reolink cameras). For example, if your stream outputs 20fps, your i-frame interval should be 20 (or 1x on Reolink). Values higher than the frame rate will cause the stream to take longer to begin playback. See [this page](https://gardinal.net/understanding-the-keyframe-interval/) for more on keyframes.
The default video and audio codec on your camera may not always be compatible with your browser, which is why setting them to H.264 and AAC is recommended. See the [go2rtc docs](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness) for codec support information.
### Audio Support
@ -32,6 +42,15 @@ go2rtc:
- "ffmpeg:http_cam#audio=opus" # <- copy of the stream which transcodes audio to the missing codec (usually will be opus)
```
If your camera does not have audio and you are having problems with Live view, you should have go2rtc send video only:
```yaml
go2rtc:
streams:
no_audio_camera:
- ffmpeg:rtsp://192.168.1.5:554/live0#video=copy
```
### Setting Stream For Live UI
There may be some cameras that you would prefer to use the sub stream for live view, but the main stream for recording. This can be done via `live -> stream_name`.

View File

@ -167,7 +167,7 @@ This detector also supports YOLOX. Frigate does not come with any YOLOX models p
#### YOLO-NAS
[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb).
[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb).
:::warning

View File

@ -0,0 +1,24 @@
---
id: pwa
title: Installing Frigate App
---
Frigate supports being installed as a [Progressive Web App](https://web.dev/explore/progressive-web-apps) on Desktop, Android, and iOS.
This adds features including the ability to deep link directly into the app.
## Requirements
In order to install Frigate as a PWA, the following requirements must be met:
- Frigate must be accessed via a secure context (localhost, secure https, etc.)
- On Android, Firefox, Chrome, Edge, Opera, and Samsung Internet Browser all support installing PWAs.
- On iOS 16.4 and later, PWAs can be installed from the Share menu in Safari, Chrome, Edge, Firefox, and Orion.
## Installation
Installation varies slightly based on the device that is being used:
- Desktop: Use the install button typically found in right edge of the address bar
- Android: Use the `Install as App` button in the more options menu
- iOS: Use the `Add to Homescreen` button in the share menu

View File

@ -334,6 +334,9 @@ review:
- car
- person
# Optional: required zones for an object to be marked as an alert (default: none)
# NOTE: when settings required zones globally, this zone must exist on all cameras
# or the config will be considered invalid. In that case the required_zones
# should be configured at the camera level.
required_zones:
- driveway
# Optional: detections configuration
@ -343,12 +346,20 @@ review:
- car
- person
# Optional: required zones for an object to be marked as a detection (default: none)
# NOTE: when settings required zones globally, this zone must exist on all cameras
# or the config will be considered invalid. In that case the required_zones
# should be configured at the camera level.
required_zones:
- driveway
# Optional: Motion configuration
# NOTE: Can be overridden at the camera level
motion:
# Optional: enables detection for the camera (default: True)
# NOTE: Motion detection is required for object detection,
# setting this to False and leaving detect enabled
# will result in an error on startup.
enabled: False
# Optional: The threshold passed to cv2.threshold to determine if a pixel is different enough to be counted as motion. (default: shown below)
# Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive.
# The value should be between 1 and 255.
@ -726,6 +737,8 @@ cameras:
genai:
# Optional: Enable AI description generation (default: shown below)
enabled: False
# Optional: Use the object snapshot instead of thumbnails for description generation (default: shown below)
use_snapshot: False
# Optional: The default prompt for generating descriptions. Can use replacement
# variables like "label", "sub_label", "camera" to make more dynamic. (default: shown below)
prompt: "Describe the {label} in the sequence of images with as much detail as possible. Do not describe the background."
@ -802,7 +815,7 @@ camera_groups:
- side_cam
- front_doorbell_cam
# Required: icon used for group
icon: car
icon: LuCar
# Required: index of this group
order: 0
```

View File

@ -41,8 +41,6 @@ review:
By default all detections that do not qualify as an alert qualify as a detection. However, detections can further be filtered to only include certain labels or certain zones.
By default a review item will only be marked as an alert if a person or car is detected. This can be configured to include any object or audio label using the following config:
```yaml
# can be overridden at the camera level
review:

View File

@ -69,6 +69,7 @@ Inference speeds vary greatly depending on the CPU, GPU, or VPU used, some known
| Intel i5 7500 | ~ 15 ms | Inference speeds on CPU were ~ 260 ms |
| Intel i5 1135G7 | 10 - 15 ms | |
| Intel i5 12600K | ~ 15 ms | Inference speeds on CPU were ~ 35 ms |
| Intel Arc A750 | ~ 4 ms | |
### TensorRT - Nvidia GPU

View File

@ -13,7 +13,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect
# Setup a go2rtc stream
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. If you set the stream name under go2rtc to match the name of your camera, it will automatically be mapped and you will get additional live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.4#module-streams), not just rtsp.
First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. For the best experience, you should set the stream name under go2rtc to match the name of your camera so that Frigate will automatically map it and be able to use better live view options for the camera. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.4#module-streams), not just rtsp.
```yaml
go2rtc:
@ -22,7 +22,7 @@ go2rtc:
- rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
```
The easiest live view to get working is MSE. After adding this to the config, restart Frigate and try to watch the live stream by selecting MSE in the dropdown after clicking on the camera.
After adding this to the config, restart Frigate and try to watch the live stream for a single camera by clicking on it from the dashboard. It should look much clearer and more fluent than the original jsmpeg stream.
### What if my video doesn't play?
@ -46,7 +46,7 @@ The easiest live view to get working is MSE. After adding this to the config, re
streams:
back:
- rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
- "ffmpeg:back#video=h264"
- "ffmpeg:back#video=h264#hardware"
```
- Switch to FFmpeg if needed:
@ -58,9 +58,8 @@ The easiest live view to get working is MSE. After adding this to the config, re
- ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
```
- If you can see the video but do not have audio, this is most likely because your
camera's audio stream is not AAC.
- If possible, update your camera's audio settings to AAC.
- If you can see the video but do not have audio, this is most likely because your camera's audio stream codec is not AAC.
- If possible, update your camera's audio settings to AAC in your camera's firmware.
- If your cameras do not support AAC audio, you will need to tell go2rtc to re-encode the audio to AAC on demand if you want audio. This will use additional CPU and add some latency. To add AAC audio on demand, you can update your go2rtc config as follows:
```yaml
go2rtc:
@ -77,7 +76,7 @@ camera's audio stream is not AAC.
streams:
back:
- rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
- "ffmpeg:back#video=h264#audio=aac"
- "ffmpeg:back#video=h264#audio=aac#hardware"
```
When using the ffmpeg module, you would add AAC audio like this:
@ -86,7 +85,7 @@ camera's audio stream is not AAC.
go2rtc:
streams:
back:
- "ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2#video=copy#audio=copy#audio=aac"
- "ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2#video=copy#audio=copy#audio=aac#hardware"
```
:::warning
@ -102,4 +101,4 @@ section.
## Next steps
1. If the stream you added to go2rtc is also used by Frigate for the `record` or `detect` role, you can migrate your config to pull from the RTSP restream to reduce the number of connections to your camera as shown [here](/configuration/restream#reduce-connections-to-camera).
1. You may also prefer to [setup WebRTC](/configuration/live#webrtc-extra-configuration) for slightly lower latency than MSE. Note that WebRTC only supports h264 and specific audio formats.
2. You may also prefer to [setup WebRTC](/configuration/live#webrtc-extra-configuration) for slightly lower latency than MSE. Note that WebRTC only supports h264 and specific audio formats and may require opening ports on your router.

View File

@ -3,25 +3,38 @@ id: reverse_proxy
title: Setting up a reverse proxy
---
This guide outlines the basic configuration steps needed to expose your Frigate UI to the internet.
A common way of accomplishing this is to use a reverse proxy webserver between your router and your Frigate instance.
A reverse proxy accepts HTTP requests from the public internet and redirects them transparently to internal webserver(s) on your network.
This guide outlines the basic configuration steps needed to set up a reverse proxy in front of your Frigate instance.
The suggested steps are:
A reverse proxy is typically needed if you want to set up Frigate on a custom URL, on a subdomain, or on a host serving multiple sites. It could also be used to set up your own authentication provider or for more advanced HTTP routing.
- **Configure** a 'proxy' HTTP webserver (such as [Apache2](https://httpd.apache.org/docs/current/) or [NPM](https://github.com/NginxProxyManager/nginx-proxy-manager)) and only expose ports 80/443 from this webserver to the internet
- **Encrypt** content from the proxy webserver by installing SSL (such as with [Let's Encrypt](https://letsencrypt.org/)). Note that SSL is then not required on your Frigate webserver as the proxy encrypts all requests for you
- **Restrict** access to your Frigate instance at the proxy using, for example, password authentication
Before setting up a reverse proxy, check if any of the built-in functionality in Frigate suits your needs:
|Topic|Docs|
|-|-|
|TLS|Please see the `tls` [configuration option](../configuration/tls.md)|
|Authentication|Please see the [authentication](../configuration/authentication.md) documentation|
|IPv6|[Enabling IPv6](../configuration/advanced.md#enabling-ipv6)
**Note about TLS**
When using a reverse proxy, the TLS session is usually terminated at the proxy, sending the internal request over plain HTTP. If this is the desired behavior, TLS must first be disabled in Frigate, or you will encounter an HTTP 400 error: "The plain HTTP request was sent to HTTPS port."
To disable TLS, set the following in your Frigate configuration:
```yml
tls:
enabled: false
```
:::warning
A reverse proxy can be used to secure access to an internal webserver but the user will be entirely reliant
on the steps they have taken. You must ensure you are following security best practices.
This page does not attempt to outline the specific steps needed to secure your internal website.
A reverse proxy can be used to secure access to an internal web server, but the user will be entirely reliant on the steps they have taken. You must ensure you are following security best practices.
This page does not attempt to outline the specific steps needed to secure your internal website.
Please use your own knowledge to assess and vet the reverse proxy software before you install anything on your system.
:::
There are several technologies available to implement reverse proxies. This document currently suggests one, using Apache2,
and the community is invited to document others through a contribution to this page.
## Proxies
There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page.
* [Apache2](#apache2-reverse-proxy)
* [Nginx](#nginx-reverse-proxy)
* [Traefik](#traefik-reverse-proxy)
## Apache2 Reverse Proxy
@ -141,3 +154,26 @@ The settings below enabled connection upgrade, sets up logging (optional) and pr
}
```
## Traefik Reverse Proxy
This example shows how to add a `label` to the Frigate Docker compose file, enabling Traefik to automatically discover your Frigate instance.
Before using the example below, you must first set up Traefik with the [Docker provider](https://doc.traefik.io/traefik/providers/docker/)
```yml
services:
frigate:
container_name: frigate
image: ghcr.io/blakeblackshear/frigate:stable
...
...
labels:
- "traefik.enable=true"
- "traefik.http.services.frigate.loadbalancer.server.port=8971"
- "traefik.http.routers.frigate.rule=Host(`traefik.example.com`)"
```
The above configuration will create a "service" in Traefik, automatically adding your container's IP on port 8971 as a backend.
It will also add a router, routing requests to "traefik.example.com" to your local container.
Note that with this approach, you don't need to expose any ports for the Frigate instance since all traffic will be routed over the internal Docker network.

View File

View File

@ -25,7 +25,7 @@ Available via HACS as a default repository. To install:
- Use [HACS](https://hacs.xyz/) to install the integration:
```
Home Assistant > HACS > Integrations > "Explore & Add Integrations" > Frigate
Home Assistant > HACS > Click in the Search bar and type "Frigate" > Frigate
```
- Restart Home Assistant.

View File

@ -11,7 +11,7 @@ These are the MQTT messages generated by Frigate. The default topic_prefix is `f
Designed to be used as an availability topic with Home Assistant. Possible message are:
"online": published when Frigate is running (on startup)
"offline": published right before Frigate stops
"offline": published after Frigate has stopped
### `frigate/restart`

View File

@ -23,7 +23,7 @@ In Frigate, you can use an environment variable or a docker secret named `PLUS_A
:::warning
You cannot use the `environment_vars` section of your configuration file to set this environment variable.
You cannot use the `environment_vars` section of your Frigate configuration file to set this environment variable. It must be defined as an environment variable in the docker config or HA addon config.
:::

View File

@ -18,3 +18,7 @@ Please use your own knowledge to assess and vet them before you install anything
[Double Take](https://github.com/skrashevich/double-take) provides an unified UI and API for processing and training images for facial recognition.
It supports automatically setting the sub labels in Frigate for person objects that are detected and recognized.
This is a fork (with fixed errors and new features) of [original Double Take](https://github.com/jakowenko/double-take) project which, unfortunately, isn't being maintained by author.
## [Frigate telegram](https://github.com/OldTyT/frigate-telegram)
[Frigate telegram](https://github.com/OldTyT/frigate-telegram) makes it possible to send events from Frigate to Telegram. Events are sent as a message with a text description, video, and thumbnail.

View File

@ -28,6 +28,18 @@ The USB coral has different IDs when it is uninitialized and initialized.
- When running Frigate in a VM, Proxmox lxc, etc. you must ensure both device IDs are mapped.
- When running HA OS you may need to run the Full Access version of the Frigate addon with the `Protected Mode` switch disabled so that the coral can be accessed.
### Synology 716+II running DSM 7.2.1-69057 Update 5
Some users have reported that this older device runs an older kernel causing issues with the coral not being detected. The following steps allowed it to be detected correctly:
1. Plug in the coral TPU in any of the USB ports on the NAS
2. Open the control panel - info screen. The coral TPU would be shown as a generic device.
3. Start the docker container with Coral TPU enabled in the config
4. The TPU would be detected but a few moments later it would disconnect.
5. While leaving the TPU device plugged in, restart the NAS using the reboot command in the UI. Do NOT unplug the NAS/power it off etc.
6. Open the control panel - info scree. The coral TPU will now be recognised as a USB Device - google inc
7. Start the frigate container. Everything should work now!
## USB Coral Detection Appears to be Stuck
The USB Coral can become stuck and need to be restarted, this can happen for a number of reasons depending on hardware and software setup. Some common reasons are:

0
docs/sidebars.js Normal file
View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -0,0 +1,9 @@
from typing import Optional
from pydantic import BaseModel
from frigate.events.types import RegenerateDescriptionEnum
class RegenerateQueryParameters(BaseModel):
source: Optional[RegenerateDescriptionEnum] = RegenerateDescriptionEnum.thumbnails

View File

@ -31,6 +31,9 @@ from frigate.api.defs.events_query_parameters import (
EventsSearchQueryParams,
EventsSummaryQueryParams,
)
from frigate.api.defs.regenerate_query_parameters import (
RegenerateQueryParameters,
)
from frigate.api.defs.tags import Tags
from frigate.const import (
CLIPS_DIR,
@ -996,7 +999,9 @@ def set_description(
@router.put("/events/{event_id}/description/regenerate")
def regenerate_description(request: Request, event_id: str):
def regenerate_description(
request: Request, event_id: str, params: RegenerateQueryParameters = Depends()
):
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
@ -1009,7 +1014,7 @@ def regenerate_description(request: Request, event_id: str):
request.app.frigate_config.semantic_search.enabled
and request.app.frigate_config.genai.enabled
):
request.app.event_metadata_updater.publish(event.id)
request.app.event_metadata_updater.publish((event.id, params.source))
return JSONResponse(
content=(
@ -1017,7 +1022,8 @@ def regenerate_description(request: Request, event_id: str):
"success": True,
"message": "Event "
+ event_id
+ " description regeneration has been requested.",
+ " description regeneration has been requested using "
+ params.source,
}
),
status_code=200,

View File

@ -1,6 +1,8 @@
"""Export apis."""
import logging
import random
import string
from pathlib import Path
from typing import Optional
@ -72,8 +74,10 @@ def export_recording(
status_code=400,
)
export_id = f"{camera_name}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
exporter = RecordingExporter(
request.app.frigate_config,
export_id,
camera_name,
friendly_name,
existing_image,
@ -91,6 +95,7 @@ def export_recording(
{
"success": True,
"message": "Starting export of recording.",
"export_id": export_id,
}
),
status_code=200,

View File

@ -14,7 +14,7 @@ import numpy as np
import pytz
from fastapi import APIRouter, Path, Query, Request, Response
from fastapi.params import Depends
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename
from peewee import DoesNotExist, fn
from tzlocal import get_localzone_name
@ -44,7 +44,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.media])
@router.get("{camera_name}")
@router.get("/{camera_name}")
def mjpeg_feed(
request: Request,
camera_name: str,
@ -60,7 +60,7 @@ def mjpeg_feed(
}
if camera_name in request.app.frigate_config.cameras:
# return a multipart response
return Response(
return StreamingResponse(
imagestream(
request.app.detected_frames_processor,
camera_name,

View File

@ -6,7 +6,7 @@ import secrets
import shutil
from multiprocessing import Queue
from multiprocessing.synchronize import Event as MpEvent
from typing import Any, Optional
from typing import Optional
import psutil
import uvicorn
@ -29,11 +29,11 @@ from frigate.comms.mqtt import MqttClient
from frigate.comms.webpush import WebPushClient
from frigate.comms.ws import WebSocketClient
from frigate.comms.zmq_proxy import ZmqProxy
from frigate.config.config import FrigateConfig
from frigate.const import (
CACHE_DIR,
CLIPS_DIR,
CONFIG_DIR,
DEFAULT_DB_PATH,
EXPORT_DIR,
MODEL_CACHE_DIR,
RECORD_DIR,
@ -77,10 +77,8 @@ logger = logging.getLogger(__name__)
class FrigateApp:
audio_process: Optional[mp.Process] = None
# TODO: Fix FrigateConfig usage, so we can properly annotate it here without mypy erroring out.
def __init__(self, config: Any) -> None:
def __init__(self, config: FrigateConfig) -> None:
self.audio_process: Optional[mp.Process] = None
self.stop_event: MpEvent = mp.Event()
self.detection_queue: Queue = mp.Queue()
self.detectors: dict[str, ObjectDetectProcess] = {}
@ -149,13 +147,6 @@ class FrigateApp:
except PermissionError:
logger.error("Unable to write to /config to save DB state")
# Migrate DB location
old_db_path = DEFAULT_DB_PATH
if not os.path.isfile(self.config.database.path) and os.path.isfile(
old_db_path
):
os.rename(old_db_path, self.config.database.path)
# Migrate DB schema
migrate_db = SqliteExtDatabase(self.config.database.path)
@ -281,7 +272,7 @@ class FrigateApp:
except PermissionError:
logger.error("Unable to write to /config to save export state")
migrate_exports(self.config.ffmpeg, self.config.cameras.keys())
migrate_exports(self.config.ffmpeg, list(self.config.cameras.keys()))
def init_external_event_processor(self) -> None:
self.external_event_processor = ExternalEventProcessor(self.config)
@ -325,7 +316,9 @@ class FrigateApp:
largest_frame = max(
[
det.model.height * det.model.width * 3
for (name, det) in self.config.detectors.items()
if det.model is not None
else 320
for det in self.config.detectors.values()
]
)
shm_in = mp.shared_memory.SharedMemory(
@ -392,6 +385,7 @@ class FrigateApp:
# create or update region grids for each camera
for camera in self.config.cameras.values():
assert camera.name is not None
self.region_grids[camera.name] = get_camera_regions_grid(
camera.name,
camera.detect,
@ -505,10 +499,10 @@ class FrigateApp:
min_req_shm += 8
available_shm = total_shm - min_req_shm
cam_total_frame_size = 0
cam_total_frame_size = 0.0
for camera in self.config.cameras.values():
if camera.enabled:
if camera.enabled and camera.detect.width and camera.detect.height:
cam_total_frame_size += round(
(camera.detect.width * camera.detect.height * 1.5 + 270480)
/ 1048576,

View File

@ -4,6 +4,8 @@ import logging
from enum import Enum
from typing import Optional
from frigate.events.types import RegenerateDescriptionEnum
from .zmq_proxy import Publisher, Subscriber
logger = logging.getLogger(__name__)
@ -23,6 +25,9 @@ class EventMetadataPublisher(Publisher):
topic = topic.value
super().__init__(topic)
def publish(self, payload: tuple[str, RegenerateDescriptionEnum]) -> None:
super().publish(payload)
class EventMetadataSubscriber(Subscriber):
"""Simplifies receiving event metadata."""
@ -35,10 +40,12 @@ class EventMetadataSubscriber(Subscriber):
def check_for_update(
self, timeout: float = None
) -> Optional[tuple[EventMetadataTypeEnum, any]]:
) -> Optional[tuple[EventMetadataTypeEnum, str, RegenerateDescriptionEnum]]:
return super().check_for_update(timeout)
def _return_object(self, topic: str, payload: any) -> any:
if payload is None:
return (None, None)
return (EventMetadataTypeEnum[topic[len(self.topic_base) :]], payload)
return (None, None, None)
topic = EventMetadataTypeEnum[topic[len(self.topic_base) :]]
event_id, source = payload
return (topic, event_id, RegenerateDescriptionEnum(source))

View File

@ -12,8 +12,7 @@ SOCKET_SUB = "ipc:///tmp/cache/proxy_sub"
class ZmqProxyRunner(threading.Thread):
def __init__(self, context: zmq.Context[zmq.Socket]) -> None:
threading.Thread.__init__(self)
self.name = "detection_proxy"
super().__init__(name="detection_proxy")
self.context = context
def run(self) -> None:

View File

@ -11,6 +11,7 @@ __all__ = ["GenAIConfig", "GenAICameraConfig", "GenAIProviderEnum"]
class GenAIProviderEnum(str, Enum):
openai = "openai"
azure_openai = "azure_openai"
gemini = "gemini"
ollama = "ollama"
@ -18,6 +19,9 @@ class GenAIProviderEnum(str, Enum):
# uses BaseModel because some global attributes are not available at the camera level
class GenAICameraConfig(BaseModel):
enabled: bool = Field(default=False, title="Enable GenAI for camera.")
use_snapshot: bool = Field(
default=False, title="Use snapshots for generating descriptions."
)
prompt: str = Field(
default="Describe the {label} in the sequence of images with as much detail as possible. Do not describe the background.",
title="Default caption prompt.",

View File

@ -289,7 +289,9 @@ class FrigateConfig(FrigateBaseModel):
default_factory=dict, title="Frigate environment variables."
)
logger: LoggerConfig = Field(
default_factory=LoggerConfig, title="Logging configuration."
default_factory=LoggerConfig,
title="Logging configuration.",
validate_default=True,
)
# Global config

View File

@ -26,9 +26,6 @@ DETECTOR_KEY = "tensorrt"
if TRT_SUPPORT:
class TrtLogger(trt.ILogger):
def __init__(self):
trt.ILogger.__init__(self)
def log(self, severity, msg):
logger.log(self.getSeverity(severity), msg)

View File

@ -3,6 +3,7 @@
import base64
import io
import logging
import os
import threading
from multiprocessing.synchronize import Event as MpEvent
from typing import Optional
@ -19,7 +20,7 @@ from frigate.comms.event_metadata_updater import (
from frigate.comms.events_updater import EventEndSubscriber, EventUpdateSubscriber
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.const import UPDATE_EVENT_DESCRIPTION
from frigate.const import CLIPS_DIR, UPDATE_EVENT_DESCRIPTION
from frigate.events.types import EventTypeEnum
from frigate.genai import get_genai_client
from frigate.models import Event
@ -136,6 +137,44 @@ class EmbeddingMaintainer(threading.Thread):
or set(event.zones) & set(camera_config.genai.required_zones)
)
):
logger.debug(
f"Description generation for {event}, has_snapshot: {event.has_snapshot}"
)
if event.has_snapshot and camera_config.genai.use_snapshot:
with open(
os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"),
"rb",
) as image_file:
snapshot_image = image_file.read()
img = cv2.imdecode(
np.frombuffer(snapshot_image, dtype=np.int8),
cv2.IMREAD_COLOR,
)
# crop snapshot based on region before sending off to genai
height, width = img.shape[:2]
x1_rel, y1_rel, width_rel, height_rel = event.data["region"]
x1, y1 = int(x1_rel * width), int(y1_rel * height)
cropped_image = img[
y1 : y1 + int(height_rel * height),
x1 : x1 + int(width_rel * width),
]
_, buffer = cv2.imencode(".jpg", cropped_image)
snapshot_image = buffer.tobytes()
embed_image = (
[snapshot_image]
if event.has_snapshot and camera_config.genai.use_snapshot
else (
[thumbnail for data in self.tracked_events[event_id]]
if len(self.tracked_events.get(event_id, [])) > 0
else [thumbnail]
)
)
# Generate the description. Call happens in a thread since it is network bound.
threading.Thread(
target=self._embed_description,
@ -143,12 +182,7 @@ class EmbeddingMaintainer(threading.Thread):
daemon=True,
args=(
event,
[
data["thumbnail"]
for data in self.tracked_events[event_id]
]
if len(self.tracked_events.get(event_id, [])) > 0
else [thumbnail],
embed_image,
metadata,
),
).start()
@ -159,13 +193,15 @@ class EmbeddingMaintainer(threading.Thread):
def _process_event_metadata(self):
# Check for regenerate description requests
(topic, event_id) = self.event_metadata_subscriber.check_for_update(timeout=1)
(topic, event_id, source) = self.event_metadata_subscriber.check_for_update(
timeout=1
)
if topic is None:
return
if event_id:
self.handle_regenerate_description(event_id)
self.handle_regenerate_description(event_id, source)
def _create_thumbnail(self, yuv_frame, box, height=500) -> Optional[bytes]:
"""Return jpg thumbnail of a region of the frame."""
@ -228,7 +264,7 @@ class EmbeddingMaintainer(threading.Thread):
description,
)
def handle_regenerate_description(self, event_id: str) -> None:
def handle_regenerate_description(self, event_id: str, source: str) -> None:
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
@ -243,4 +279,40 @@ class EmbeddingMaintainer(threading.Thread):
metadata = get_metadata(event)
thumbnail = base64.b64decode(event.thumbnail)
self._embed_description(event, [thumbnail], metadata)
logger.debug(
f"Trying {source} regeneration for {event}, has_snapshot: {event.has_snapshot}"
)
if event.has_snapshot and source == "snapshot":
with open(
os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"),
"rb",
) as image_file:
snapshot_image = image_file.read()
img = cv2.imdecode(
np.frombuffer(snapshot_image, dtype=np.int8), cv2.IMREAD_COLOR
)
# crop snapshot based on region before sending off to genai
height, width = img.shape[:2]
x1_rel, y1_rel, width_rel, height_rel = event.data["region"]
x1, y1 = int(x1_rel * width), int(y1_rel * height)
cropped_image = img[
y1 : y1 + int(height_rel * height), x1 : x1 + int(width_rel * width)
]
_, buffer = cv2.imencode(".jpg", cropped_image)
snapshot_image = buffer.tobytes()
embed_image = (
[snapshot_image]
if event.has_snapshot and source == "snapshot"
else (
[thumbnail for data in self.tracked_events[event_id]]
if len(self.tracked_events.get(event_id, [])) > 0
else [thumbnail]
)
)
self._embed_description(event, embed_image, metadata)

View File

@ -2,8 +2,6 @@
import datetime
import logging
import signal
import sys
import threading
import time
from typing import Tuple
@ -73,46 +71,42 @@ class AudioProcessor(util.Process):
):
super().__init__(name="frigate.audio_manager", daemon=True)
self.logger = logging.getLogger(self.name)
self.camera_metrics = camera_metrics
self.cameras = cameras
def run(self) -> None:
stop_event = threading.Event()
audio_threads: list[AudioEventMaintainer] = []
threading.current_thread().name = "process:audio_manager"
signal.signal(signal.SIGTERM, lambda sig, frame: sys.exit())
if len(self.cameras) == 0:
return
try:
for camera in self.cameras:
audio_thread = AudioEventMaintainer(
camera,
self.camera_metrics,
stop_event,
)
audio_threads.append(audio_thread)
audio_thread.start()
for camera in self.cameras:
audio_thread = AudioEventMaintainer(
camera,
self.camera_metrics,
self.stop_event,
)
audio_threads.append(audio_thread)
audio_thread.start()
self.logger.info(f"Audio processor started (pid: {self.pid})")
self.logger.info(f"Audio processor started (pid: {self.pid})")
while True:
signal.pause()
finally:
stop_event.set()
for thread in audio_threads:
thread.join(1)
if thread.is_alive():
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
thread.join(10)
while not self.stop_event.wait():
pass
for thread in audio_threads:
if thread.is_alive():
self.logger.warning(f"Thread {thread.name} is still alive")
self.logger.info("Exiting audio processor")
for thread in audio_threads:
thread.join(1)
if thread.is_alive():
self.logger.info(f"Waiting for thread {thread.name:s} to exit")
thread.join(10)
for thread in audio_threads:
if thread.is_alive():
self.logger.warning(f"Thread {thread.name} is still alive")
self.logger.info("Exiting audio processor")
class AudioEventMaintainer(threading.Thread):

View File

@ -23,8 +23,7 @@ class EventCleanupType(str, Enum):
class EventCleanup(threading.Thread):
def __init__(self, config: FrigateConfig, stop_event: MpEvent):
threading.Thread.__init__(self)
self.name = "event_cleanup"
super().__init__(name="event_cleanup")
self.config = config
self.stop_event = stop_event
self.camera_keys = list(self.config.cameras.keys())

View File

@ -54,8 +54,7 @@ class EventProcessor(threading.Thread):
timeline_queue: Queue,
stop_event: MpEvent,
):
threading.Thread.__init__(self)
self.name = "event_processor"
super().__init__(name="event_processor")
self.config = config
self.timeline_queue = timeline_queue
self.events_in_process: Dict[str, Event] = {}
@ -125,6 +124,9 @@ class EventProcessor(threading.Thread):
updated_db = False
# if this is the first message, just store it and continue, its not time to insert it in the db
if event_type == EventStateEnum.start:
self.events_in_process[event_data["id"]] = event_data
if should_update_db(self.events_in_process[event_data["id"]], event_data):
updated_db = True
camera_config = self.config.cameras[camera]

View File

@ -12,3 +12,8 @@ class EventStateEnum(str, Enum):
start = "start"
update = "update"
end = "end"
class RegenerateDescriptionEnum(str, Enum):
thumbnails = "thumbnails"
snapshot = "snapshot"

View File

@ -32,7 +32,7 @@ class LibvaGpuSelector:
devices = list(filter(lambda d: d.startswith("render"), os.listdir("/dev/dri")))
if len(devices) < 2:
self._selected_gpu = "/dev/dri/renderD128"
self._selected_gpu = f"/dev/dri/{devices[0]}"
return self._selected_gpu
for device in devices:
@ -91,10 +91,10 @@ PRESETS_HW_ACCEL_DECODE["preset-nvidia-mjpeg"] = PRESETS_HW_ACCEL_DECODE[
PRESETS_HW_ACCEL_SCALE = {
"preset-rpi-64-h264": "-r {0} -vf fps={0},scale={1}:{2}",
"preset-rpi-64-h265": "-r {0} -vf fps={0},scale={1}:{2}",
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12,eq=gamma=1.05",
FFMPEG_HWACCEL_VAAPI: "-r {0} -vf fps={0},scale_vaapi=w={1}:h={2},hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5",
"preset-intel-qsv-h264": "-r {0} -vf vpp_qsv=framerate={0}:w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
"preset-intel-qsv-h265": "-r {0} -vf vpp_qsv=framerate={0}:w={1}:h={2}:format=nv12,hwdownload,format=nv12,format=yuv420p",
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12,eq=gamma=1.05",
FFMPEG_HWACCEL_NVIDIA: "-r {0} -vf fps={0},scale_cuda=w={1}:h={2},hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5",
"preset-jetson-h264": "-r {0}", # scaled in decoder
"preset-jetson-h265": "-r {0}", # scaled in decoder
"preset-rk-h264": "-r {0} -vf scale_rkrga=w={1}:h={2}:format=yuv420p:force_original_aspect_ratio=0,hwmap=mode=read,format=yuv420p",
@ -186,11 +186,11 @@ def parse_preset_hardware_acceleration_scale(
scale = PRESETS_HW_ACCEL_SCALE.get(arg, PRESETS_HW_ACCEL_SCALE["default"])
if (
",hwdownload,format=nv12,eq=gamma=1.05" in scale
",hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5" in scale
and os.environ.get("FFMPEG_DISABLE_GAMMA_EQUALIZER") is not None
):
scale.replace(
",hwdownload,format=nv12,eq=gamma=1.05",
",hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5",
":format=nv12,hwdownload,format=nv12,format=yuv420p",
)

View File

@ -0,0 +1,73 @@
"""Azure OpenAI Provider for Frigate AI."""
import base64
import logging
from typing import Optional
from urllib.parse import parse_qs, urlparse
from openai import AzureOpenAI
from frigate.config import GenAIProviderEnum
from frigate.genai import GenAIClient, register_genai_provider
logger = logging.getLogger(__name__)
@register_genai_provider(GenAIProviderEnum.azure_openai)
class OpenAIClient(GenAIClient):
"""Generative AI client for Frigate using Azure OpenAI."""
provider: AzureOpenAI
def _init_provider(self):
"""Initialize the client."""
try:
parsed_url = urlparse(self.genai_config.base_url)
query_params = parse_qs(parsed_url.query)
api_version = query_params.get("api-version", [None])[0]
azure_endpoint = f"{parsed_url.scheme}://{parsed_url.netloc}/"
if not api_version:
logger.warning("Azure OpenAI url is missing API version.")
return None
except Exception as e:
logger.warning("Error parsing Azure OpenAI url: %s", str(e))
return None
return AzureOpenAI(
api_key=self.genai_config.api_key,
api_version=api_version,
azure_endpoint=azure_endpoint,
)
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
"""Submit a request to Azure OpenAI."""
encoded_images = [base64.b64encode(image).decode("utf-8") for image in images]
try:
result = self.provider.chat.completions.create(
model=self.genai_config.model,
messages=[
{
"role": "user",
"content": [{"type": "text", "text": prompt}]
+ [
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image}",
"detail": "low",
},
}
for image in encoded_images
],
},
],
timeout=self.timeout,
)
except Exception as e:
logger.warning("Azure OpenAI returned an error: %s", str(e))
return None
if len(result.choices) > 0:
return result.choices[0].message.content.strip()
return None

View File

@ -70,8 +70,7 @@ os.register_at_fork(after_in_child=reopen_std_streams)
class LogPipe(threading.Thread):
def __init__(self, log_name: str):
"""Setup the object with a logger and start the thread"""
threading.Thread.__init__(self)
self.daemon = False
super().__init__(daemon=False)
self.logger = logging.getLogger(log_name)
self.level = logging.ERROR
self.deque: Deque[str] = deque(maxlen=100)

View File

@ -12,7 +12,8 @@ from setproctitle import setproctitle
import frigate.util as util
from frigate.detectors import create_detector
from frigate.detectors.detector_config import InputTensorEnum
from frigate.detectors.detector_config import BaseDetectorConfig, InputTensorEnum
from frigate.detectors.plugins.rocm import DETECTOR_KEY as ROCM_DETECTOR_KEY
from frigate.util.builtin import EventsPerSecond, load_labels
from frigate.util.image import SharedMemoryFrameManager
from frigate.util.services import listen
@ -22,11 +23,11 @@ logger = logging.getLogger(__name__)
class ObjectDetector(ABC):
@abstractmethod
def detect(self, tensor_input, threshold=0.4):
def detect(self, tensor_input, threshold: float = 0.4):
pass
def tensor_transform(desired_shape):
def tensor_transform(desired_shape: InputTensorEnum):
# Currently this function only supports BHWC permutations
if desired_shape == InputTensorEnum.nhwc:
return None
@ -37,8 +38,8 @@ def tensor_transform(desired_shape):
class LocalObjectDetector(ObjectDetector):
def __init__(
self,
detector_config=None,
labels=None,
detector_config: BaseDetectorConfig = None,
labels: str = None,
):
self.fps = EventsPerSecond()
if labels is None:
@ -47,7 +48,13 @@ class LocalObjectDetector(ObjectDetector):
self.labels = load_labels(labels)
if detector_config:
self.input_transform = tensor_transform(detector_config.model.input_tensor)
if detector_config.type == ROCM_DETECTOR_KEY:
# ROCm requires NHWC as input
self.input_transform = None
else:
self.input_transform = tensor_transform(
detector_config.model.input_tensor
)
else:
self.input_transform = None

View File

@ -108,7 +108,12 @@ def is_better_thumbnail(label, current_thumb, new_obj, frame_shape) -> bool:
class TrackedObject:
def __init__(
self, camera, colormap, camera_config: CameraConfig, frame_cache, obj_data
self,
camera,
colormap,
camera_config: CameraConfig,
frame_cache,
obj_data: dict[str, any],
):
# set the score history then remove as it is not part of object state
self.score_history = obj_data["score_history"]
@ -227,8 +232,8 @@ class TrackedObject:
if self.attributes[attr["label"]] < attr["score"]:
self.attributes[attr["label"]] = attr["score"]
# populate the sub_label for car with highest scoring logo
if self.obj_data["label"] == "car":
# populate the sub_label for object with highest scoring logo
if self.obj_data["label"] in ["car", "package", "person"]:
recognized_logos = {
k: self.attributes[k]
for k in ["ups", "fedex", "amazon"]
@ -236,7 +241,13 @@ class TrackedObject:
}
if len(recognized_logos) > 0:
max_logo = max(recognized_logos, key=recognized_logos.get)
self.obj_data["sub_label"] = (max_logo, recognized_logos[max_logo])
# don't overwrite sub label if it is already set
if (
self.obj_data.get("sub_label") is None
or self.obj_data["sub_label"][0] == max_logo
):
self.obj_data["sub_label"] = (max_logo, recognized_logos[max_logo])
# check for significant change
if not self.false_positive:
@ -921,8 +932,7 @@ class TrackedObjectProcessor(threading.Thread):
ptz_autotracker_thread,
stop_event,
):
threading.Thread.__init__(self)
self.name = "detected_frames_processor"
super().__init__(name="detected_frames_processor")
self.config = config
self.dispatcher = dispatcher
self.tracked_objects_queue = tracked_objects_queue

View File

@ -122,8 +122,7 @@ class FFMpegConverter(threading.Thread):
quality: int,
birdseye_rtsp: bool = False,
):
threading.Thread.__init__(self)
self.name = "birdseye_output_converter"
super().__init__(name="birdseye_output_converter")
self.camera = "birdseye"
self.input_queue = input_queue
self.stop_event = stop_event
@ -235,7 +234,7 @@ class BroadcastThread(threading.Thread):
websocket_server,
stop_event: mp.Event,
):
super(BroadcastThread, self).__init__()
super().__init__()
self.camera = camera
self.converter = converter
self.websocket_server = websocket_server

View File

@ -24,8 +24,7 @@ class FFMpegConverter(threading.Thread):
out_height: int,
quality: int,
):
threading.Thread.__init__(self)
self.name = f"{camera}_output_converter"
super().__init__(name=f"{camera}_output_converter")
self.camera = camera
self.input_queue = input_queue
self.stop_event = stop_event
@ -102,7 +101,7 @@ class BroadcastThread(threading.Thread):
websocket_server,
stop_event: mp.Event,
):
super(BroadcastThread, self).__init__()
super().__init__()
self.camera = camera
self.converter = converter
self.websocket_server = websocket_server

View File

@ -66,8 +66,7 @@ class FFMpegConverter(threading.Thread):
frame_times: list[float],
requestor: InterProcessRequestor,
):
threading.Thread.__init__(self)
self.name = f"{config.name}_preview_converter"
super().__init__(name=f"{config.name}_preview_converter")
self.config = config
self.frame_times = frame_times
self.requestor = requestor

View File

@ -149,8 +149,7 @@ class PtzAutoTrackerThread(threading.Thread):
dispatcher: Dispatcher,
stop_event: MpEvent,
) -> None:
threading.Thread.__init__(self)
self.name = "ptz_autotracker"
super().__init__(name="ptz_autotracker")
self.ptz_autotracker = PtzAutoTracker(
config, onvif, ptz_metrics, dispatcher, stop_event
)
@ -325,6 +324,12 @@ class PtzAutoTracker:
def _write_config(self, camera):
config_file = os.environ.get("CONFIG_FILE", f"{CONFIG_DIR}/config.yml")
# Check if we can use .yaml instead of .yml
config_file_yaml = config_file.replace(".yml", ".yaml")
if os.path.isfile(config_file_yaml):
config_file = config_file_yaml
logger.debug(
f"{camera}: Writing new config with autotracker motion coefficients: {self.config.cameras[camera].onvif.autotracking.movement_weights}"
)

View File

@ -406,19 +406,19 @@ class OnvifController:
# The onvif spec says this can report as +INF and -INF, so this may need to be modified
pan = numpy.interp(
pan,
[-1, 1],
[
self.cams[camera_name]["relative_fov_range"]["XRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["XRange"]["Max"],
],
[-1, 1],
)
tilt = numpy.interp(
tilt,
[-1, 1],
[
self.cams[camera_name]["relative_fov_range"]["YRange"]["Min"],
self.cams[camera_name]["relative_fov_range"]["YRange"]["Max"],
],
[-1, 1],
)
move_request.Speed = {
@ -531,11 +531,11 @@ class OnvifController:
# function takes in 0 to 1 for zoom, interpolate to the values of the camera.
zoom = numpy.interp(
zoom,
[0, 1],
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
],
[0, 1],
)
move_request.Speed = {"Zoom": speed}
@ -686,11 +686,11 @@ class OnvifController:
# store absolute zoom level as 0 to 1 interpolated from the values of the camera
self.ptz_metrics[camera_name].zoom_level.value = numpy.interp(
round(status.Position.Zoom.x, 2),
[0, 1],
[
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Min"],
self.cams[camera_name]["absolute_zoom_range"]["XRange"]["Max"],
],
[0, 1],
)
logger.debug(
f"{camera_name}: Camera zoom level: {self.ptz_metrics[camera_name].zoom_level.value}"

View File

@ -23,8 +23,7 @@ class RecordingCleanup(threading.Thread):
"""Cleanup existing recordings based on retention config."""
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
threading.Thread.__init__(self)
self.name = "recording_cleanup"
super().__init__(name="recording_cleanup")
self.config = config
self.stop_event = stop_event

View File

@ -49,6 +49,7 @@ class RecordingExporter(threading.Thread):
def __init__(
self,
config: FrigateConfig,
id: str,
camera: str,
name: Optional[str],
image: Optional[str],
@ -56,8 +57,9 @@ class RecordingExporter(threading.Thread):
end_time: int,
playback_factor: PlaybackFactorEnum,
) -> None:
threading.Thread.__init__(self)
super().__init__()
self.config = config
self.export_id = id
self.camera = camera
self.user_provided_name = name
self.user_provided_image = image
@ -172,18 +174,17 @@ class RecordingExporter(threading.Thread):
logger.debug(
f"Beginning export for {self.camera} from {self.start_time} to {self.end_time}"
)
export_id = f"{self.camera}_{''.join(random.choices(string.ascii_lowercase + string.digits, k=6))}"
export_name = (
self.user_provided_name
or f"{self.camera.replace('_', ' ')} {self.get_datetime_from_timestamp(self.start_time)} {self.get_datetime_from_timestamp(self.end_time)}"
)
video_path = f"{EXPORT_DIR}/{export_id}.mp4"
video_path = f"{EXPORT_DIR}/{self.export_id}.mp4"
thumb_path = self.save_thumbnail(export_id)
thumb_path = self.save_thumbnail(self.export_id)
Export.insert(
{
Export.id: export_id,
Export.id: self.export_id,
Export.camera: self.camera,
Export.name: export_name,
Export.date: self.start_time,
@ -257,12 +258,12 @@ class RecordingExporter(threading.Thread):
)
logger.error(p.stderr)
Path(video_path).unlink(missing_ok=True)
Export.delete().where(Export.id == export_id).execute()
Export.delete().where(Export.id == self.export_id).execute()
Path(thumb_path).unlink(missing_ok=True)
return
else:
Export.update({Export.in_progress: False}).where(
Export.id == export_id
Export.id == self.export_id
).execute()
logger.debug(f"Finished exporting {video_path}")

View File

@ -62,8 +62,7 @@ class SegmentInfo:
class RecordingMaintainer(threading.Thread):
def __init__(self, config: FrigateConfig, stop_event: MpEvent):
threading.Thread.__init__(self)
self.name = "recording_maintainer"
super().__init__(name="recording_maintainer")
self.config = config
# create communication for retained recordings
@ -129,10 +128,23 @@ class RecordingMaintainer(threading.Thread):
grouped_recordings[camera], key=lambda s: s["start_time"]
)
segment_count = len(grouped_recordings[camera])
if segment_count > keep_count:
camera_info = self.object_recordings_info[camera]
most_recently_processed_frame_time = (
camera_info[-1][0] if len(camera_info) > 0 else 0
)
processed_segment_count = len(
list(
filter(
lambda r: r["start_time"].timestamp()
< most_recently_processed_frame_time,
grouped_recordings[camera],
)
)
)
if processed_segment_count > keep_count:
logger.warning(
f"Unable to keep up with recording segments in cache for {camera}. Keeping the {keep_count} most recent segments out of {segment_count} and discarding the rest..."
f"Unable to keep up with recording segments in cache for {camera}. Keeping the {keep_count} most recent segments out of {processed_segment_count} and discarding the rest..."
)
to_remove = grouped_recordings[camera][:-keep_count]
for rec in to_remove:

View File

@ -146,8 +146,7 @@ class ReviewSegmentMaintainer(threading.Thread):
"""Maintain review segments."""
def __init__(self, config: FrigateConfig, stop_event: MpEvent):
threading.Thread.__init__(self)
self.name = "review_segment_maintainer"
super().__init__(name="review_segment_maintainer")
self.config = config
self.active_review_segments: dict[str, Optional[PendingReviewSegment]] = {}
self.frame_manager = SharedMemoryFrameManager()

View File

@ -27,8 +27,7 @@ class StatsEmitter(threading.Thread):
stats_tracking: StatsTrackingTypes,
stop_event: MpEvent,
):
threading.Thread.__init__(self)
self.name = "frigate_stats_emitter"
super().__init__(name="frigate_stats_emitter")
self.config = config
self.stats_tracking = stats_tracking
self.stop_event = stop_event

View File

@ -22,8 +22,7 @@ class StorageMaintainer(threading.Thread):
"""Maintain frigates recording storage."""
def __init__(self, config: FrigateConfig, stop_event) -> None:
threading.Thread.__init__(self)
self.name = "storage_maintainer"
super().__init__(name="storage_maintainer")
self.config = config
self.stop_event = stop_event
self.camera_storage_stats: dict[str, dict] = {}

View File

@ -74,7 +74,7 @@ class TestFfmpegPresets(unittest.TestCase):
" ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"])
)
assert (
"fps=10,scale_cuda=w=2560:h=1920,hwdownload,format=nv12,eq=gamma=1.05"
"fps=10,scale_cuda=w=2560:h=1920,hwdownload,format=nv12,eq=gamma=1.4:gamma_weight=0.5"
in (" ".join(frigate_config.cameras["back"].ffmpeg_cmds[0]["cmd"]))
)

View File

@ -39,7 +39,8 @@ class TestGpuStats(unittest.TestCase):
process.stdout = self.intel_results
sp.return_value = process
intel_stats = get_intel_gpu_stats()
print(f"the intel stats are {intel_stats}")
assert intel_stats == {
"gpu": "1.34%",
"gpu": "1.13%",
"mem": "-%",
}

View File

@ -0,0 +1,50 @@
import unittest
from frigate.track.object_attribute import ObjectAttribute
class TestAttribute(unittest.TestCase):
def test_overlapping_object_selection(self) -> None:
attribute = ObjectAttribute(
(
"amazon",
0.80078125,
(847, 242, 883, 255),
468,
2.769230769230769,
(702, 134, 1050, 482),
)
)
objects = [
{
"label": "car",
"score": 0.98828125,
"box": (728, 223, 1266, 719),
"area": 266848,
"ratio": 1.0846774193548387,
"region": (349, 0, 1397, 1048),
"frame_time": 1727785394.498972,
"centroid": (997, 471),
"id": "1727785349.150633-408hal",
"start_time": 1727785349.150633,
"motionless_count": 362,
"position_changes": 0,
"score_history": [0.98828125, 0.95703125, 0.98828125, 0.98828125],
},
{
"label": "person",
"score": 0.76953125,
"box": (826, 172, 939, 417),
"area": 27685,
"ratio": 0.46122448979591835,
"region": (702, 134, 1050, 482),
"frame_time": 1727785394.498972,
"centroid": (882, 294),
"id": "1727785390.499768-9fbhem",
"start_time": 1727785390.499768,
"motionless_count": 2,
"position_changes": 1,
"score_history": [0.8828125, 0.83984375, 0.91796875, 0.94140625],
},
]
assert attribute.find_best_object(objects) == "1727785390.499768-9fbhem"

View File

@ -7,7 +7,7 @@ from multiprocessing import Queue
from multiprocessing.synchronize import Event as MpEvent
from frigate.config import FrigateConfig
from frigate.events.maintainer import EventTypeEnum
from frigate.events.maintainer import EventStateEnum, EventTypeEnum
from frigate.models import Timeline
from frigate.util.builtin import to_relative_box
@ -23,8 +23,7 @@ class TimelineProcessor(threading.Thread):
queue: Queue,
stop_event: MpEvent,
) -> None:
threading.Thread.__init__(self)
self.name = "timeline_processor"
super().__init__(name="timeline_processor")
self.config = config
self.queue = queue
self.stop_event = stop_event
@ -44,10 +43,13 @@ class TimelineProcessor(threading.Thread):
continue
if input_type == EventTypeEnum.tracked_object:
if prev_event_data is not None and event_data is not None:
self.handle_object_detection(
camera, event_type, prev_event_data, event_data
)
# None prev_event_data is only allowed for the start of an event
if event_type != EventStateEnum.start and prev_event_data is None:
continue
self.handle_object_detection(
camera, event_type, prev_event_data, event_data
)
elif input_type == EventTypeEnum.api:
self.handle_api_entry(camera, event_type, event_data)
@ -118,10 +120,10 @@ class TimelineProcessor(threading.Thread):
for e in self.pre_event_cache[event_id]:
e[Timeline.data]["sub_label"] = event_data["sub_label"]
if event_type == "start":
if event_type == EventStateEnum.start:
timeline_entry[Timeline.class_type] = "visible"
save = True
elif event_type == "update":
elif event_type == EventStateEnum.update:
if (
len(prev_event_data["current_zones"]) < len(event_data["current_zones"])
and not event_data["stationary"]
@ -140,7 +142,7 @@ class TimelineProcessor(threading.Thread):
event_data["attributes"].keys()
)[0]
save = True
elif event_type == "end":
elif event_type == EventStateEnum.end:
timeline_entry[Timeline.class_type] = "gone"
save = True

View File

@ -0,0 +1,44 @@
"""Object attribute."""
from frigate.util.object import area, box_inside
class ObjectAttribute:
def __init__(self, raw_data: tuple) -> None:
self.label = raw_data[0]
self.score = raw_data[1]
self.box = raw_data[2]
self.area = raw_data[3]
self.ratio = raw_data[4]
self.region = raw_data[5]
def get_tracking_data(self) -> dict[str, any]:
"""Return data saved to the object."""
return {
"label": self.label,
"score": self.score,
"box": self.box,
}
def find_best_object(self, objects: list[dict[str, any]]) -> str:
"""Find the best attribute for each object and return its ID."""
best_object_area = None
best_object_id = None
for obj in objects:
if not box_inside(obj["box"], self.box):
continue
object_area = area(obj["box"])
# if multiple objects have the same attribute then they
# are overlapping, it is most likely that the smaller object
# is the one with the attribute
if best_object_area is None:
best_object_area = object_area
best_object_id = obj["id"]
elif object_area < best_object_area:
best_object_area = object_area
best_object_id = obj["id"]
return best_object_id

View File

@ -198,12 +198,23 @@ def update_yaml_from_url(file_path, url):
def update_yaml_file(file_path, key_path, new_value):
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
with open(file_path, "r") as f:
data = yaml.load(f)
try:
with open(file_path, "r") as f:
data = yaml.load(f)
except FileNotFoundError:
logger.error(
f"Unable to read from Frigate config file {file_path}. Make sure it exists and is readable."
)
return
data = update_yaml(data, key_path, new_value)
with open(file_path, "w") as f:
yaml.dump(data, f)
try:
with open(file_path, "w") as f:
yaml.dump(data, f)
except Exception as e:
logger.error(f"Unable to write to Frigate config file {file_path}: {e}")
def update_yaml(data, key_path, new_value):

View File

@ -1,15 +1,29 @@
import faulthandler
import logging
import multiprocessing as mp
import signal
import sys
import threading
from functools import wraps
from logging.handlers import QueueHandler
from typing import Any
from typing import Any, Callable, Optional
import frigate.log
class BaseProcess(mp.Process):
def __init__(self, **kwargs):
super().__init__(**kwargs)
def __init__(
self,
*,
name: Optional[str] = None,
target: Optional[Callable] = None,
args: tuple = (),
kwargs: dict = {},
daemon: Optional[bool] = None,
):
super().__init__(
name=name, target=target, args=args, kwargs=kwargs, daemon=daemon
)
def start(self, *args, **kwargs):
self.before_start()
@ -46,10 +60,36 @@ class BaseProcess(mp.Process):
class Process(BaseProcess):
logger: logging.Logger
@property
def stop_event(self) -> threading.Event:
# Lazily create the stop_event. This allows the signal handler to tell if anyone is
# monitoring the stop event, and to raise a SystemExit if not.
if "stop_event" not in self.__dict__:
self.__dict__["stop_event"] = threading.Event()
return self.__dict__["stop_event"]
def before_start(self) -> None:
self.__log_queue = frigate.log.log_listener.queue
def before_run(self) -> None:
if self.__log_queue:
logging.basicConfig(handlers=[], force=True)
logging.getLogger().addHandler(QueueHandler(self.__log_queue))
faulthandler.enable()
def receiveSignal(signalNumber, frame):
# Get the stop_event through the dict to bypass lazy initialization.
stop_event = self.__dict__.get("stop_event")
if stop_event is not None:
# Someone is monitoring stop_event. We should set it.
stop_event.set()
else:
# Nobody is monitoring stop_event. We should raise SystemExit.
sys.exit()
signal.signal(signal.SIGTERM, receiveSignal)
signal.signal(signal.SIGINT, receiveSignal)
self.logger = logging.getLogger(self.name)
logging.basicConfig(handlers=[], force=True)
logging.getLogger().addHandler(QueueHandler(self.__log_queue))

View File

@ -279,35 +279,61 @@ def get_intel_gpu_stats() -> dict[str, str]:
logger.error(f"Unable to poll intel GPU stats: {p.stderr}")
return None
else:
reading = "".join(p.stdout.split())
data = json.loads(f'[{"".join(p.stdout.split())}]')
results: dict[str, str] = {}
render = {"global": []}
video = {"global": []}
# render is used for qsv
render = []
for result in re.findall(r'"Render/3D/0":{[a-z":\d.,%]+}', reading):
packet = json.loads(result[14:])
single = packet.get("busy", 0.0)
render.append(float(single))
for block in data:
global_engine = block.get("engines")
if render:
render_avg = sum(render) / len(render)
else:
render_avg = 1
if global_engine:
render_frame = global_engine.get("Render/3D/0", {}).get("busy")
video_frame = global_engine.get("Video/0", {}).get("busy")
# video is used for vaapi
video = []
for result in re.findall(r'"Video/\d":{[a-z":\d.,%]+}', reading):
packet = json.loads(result[10:])
single = packet.get("busy", 0.0)
video.append(float(single))
if render_frame is not None:
render["global"].append(float(render_frame))
if video:
video_avg = sum(video) / len(video)
else:
video_avg = 1
if video_frame is not None:
video["global"].append(float(video_frame))
results["gpu"] = f"{round((video_avg + render_avg) / 2, 2)}%"
clients = block.get("clients", {})
if clients and len(clients):
for client_block in clients.values():
key = client_block["pid"]
if render.get(key) is None:
render[key] = []
video[key] = []
client_engine = client_block.get("engine-classes", {})
render_frame = client_engine.get("Render/3D", {}).get("busy")
video_frame = client_engine.get("Video", {}).get("busy")
if render_frame is not None:
render[key].append(float(render_frame))
if video_frame is not None:
video[key].append(float(video_frame))
results["gpu"] = (
f"{round(((sum(render['global']) / len(render['global'])) + (sum(video['global']) / len(video['global']))) / 2, 2)}%"
)
results["mem"] = "-%"
if len(render.keys()) > 1:
results["clients"] = {}
for key in render.keys():
if key == "global":
continue
results["clients"][key] = (
f"{round(((sum(render[key]) / len(render[key])) + (sum(video[key]) / len(video[key]))) / 2, 2)}%"
)
return results

View File

@ -27,6 +27,7 @@ from frigate.object_detection import RemoteObjectDetector
from frigate.ptz.autotrack import ptz_moving_at_frame_time
from frigate.track import ObjectTracker
from frigate.track.norfair_tracker import NorfairTracker
from frigate.track.object_attribute import ObjectAttribute
from frigate.util.builtin import EventsPerSecond, get_tomorrow_at_time
from frigate.util.image import (
FrameManager,
@ -34,7 +35,6 @@ from frigate.util.image import (
draw_box_with_label,
)
from frigate.util.object import (
box_inside,
create_tensor_input,
get_cluster_candidates,
get_cluster_region,
@ -94,6 +94,7 @@ def capture_frames(
ffmpeg_process,
config: CameraConfig,
shm_frame_count: int,
shm_frames: list[str],
frame_shape,
frame_manager: FrameManager,
frame_queue,
@ -108,8 +109,6 @@ def capture_frames(
skipped_eps = EventsPerSecond()
skipped_eps.start()
shm_frames: list[str] = []
while True:
fps.value = frame_rate.eps()
skipped_fps.value = skipped_eps.eps()
@ -154,10 +153,6 @@ def capture_frames(
# if the queue is full, skip this frame
skipped_eps.update()
# clear out frames
for frame in shm_frames:
frame_manager.delete(frame)
class CameraWatchdog(threading.Thread):
def __init__(
@ -176,6 +171,7 @@ class CameraWatchdog(threading.Thread):
self.camera_name = camera_name
self.config = config
self.shm_frame_count = shm_frame_count
self.shm_frames: list[str] = []
self.capture_thread = None
self.ffmpeg_detect_process = None
self.logpipe = LogPipe(f"ffmpeg.{self.camera_name}.detect")
@ -308,6 +304,7 @@ class CameraWatchdog(threading.Thread):
self.capture_thread = CameraCapture(
self.config,
self.shm_frame_count,
self.shm_frames,
self.ffmpeg_detect_process,
self.frame_shape,
self.frame_queue,
@ -348,6 +345,7 @@ class CameraCapture(threading.Thread):
self,
config: CameraConfig,
shm_frame_count: int,
shm_frames: list[str],
ffmpeg_process,
frame_shape,
frame_queue,
@ -359,6 +357,7 @@ class CameraCapture(threading.Thread):
self.name = f"capture:{config.name}"
self.config = config
self.shm_frame_count = shm_frame_count
self.shm_frames = shm_frames
self.frame_shape = frame_shape
self.frame_queue = frame_queue
self.fps = fps
@ -374,6 +373,7 @@ class CameraCapture(threading.Thread):
self.ffmpeg_process,
self.config,
self.shm_frame_count,
self.shm_frames,
self.frame_shape,
self.frame_manager,
self.frame_queue,
@ -734,29 +734,34 @@ def process_frames(
object_tracker.update_frame_times(frame_time)
# group the attribute detections based on what label they apply to
attribute_detections = {}
attribute_detections: dict[str, list[ObjectAttribute]] = {}
for label, attribute_labels in model_config.attributes_map.items():
attribute_detections[label] = [
d for d in consolidated_detections if d[0] in attribute_labels
ObjectAttribute(d)
for d in consolidated_detections
if d[0] in attribute_labels
]
# build detections and add attributes
# build detections
detections = {}
for obj in object_tracker.tracked_objects.values():
attributes = []
# if the objects label has associated attribute detections
if obj["label"] in attribute_detections.keys():
# add them to attributes if they intersect
for attribute_detection in attribute_detections[obj["label"]]:
if box_inside(obj["box"], (attribute_detection[2])):
attributes.append(
{
"label": attribute_detection[0],
"score": attribute_detection[1],
"box": attribute_detection[2],
}
)
detections[obj["id"]] = {**obj, "attributes": attributes}
detections[obj["id"]] = {**obj, "attributes": []}
# find the best object for each attribute to be assigned to
all_objects: list[dict[str, any]] = object_tracker.tracked_objects.values()
for attributes in attribute_detections.values():
for attribute in attributes:
filtered_objects = filter(
lambda o: attribute.label
in model_config.attributes_map.get(o["label"], []),
all_objects,
)
selected_object_id = attribute.find_best_object(filtered_objects)
if selected_object_id is not None:
detections[selected_object_id]["attributes"].append(
attribute.get_tracking_data()
)
# debug object tracking
if False:

View File

@ -12,8 +12,7 @@ logger = logging.getLogger(__name__)
class FrigateWatchdog(threading.Thread):
def __init__(self, detectors: dict[str, ObjectDetectProcess], stop_event: MpEvent):
threading.Thread.__init__(self)
self.name = "frigate_watchdog"
super().__init__(name="frigate_watchdog")
self.detectors = detectors
self.stop_event = stop_event

View File

@ -17,6 +17,7 @@ import { usePersistence } from "@/hooks/use-persistence";
import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils";
type AnimatedEventCardProps = {
event: ReviewSegment;
@ -107,9 +108,9 @@ export function AnimatedEventCard({
<Tooltip>
<TooltipTrigger asChild>
<div
className="relative h-24 4k:h-32"
className="relative h-24 flex-shrink-0 overflow-hidden rounded md:rounded-lg 4k:h-32"
style={{
aspectRatio: aspectRatio,
aspectRatio: alertVideos ? aspectRatio : undefined,
}}
onMouseEnter={isDesktop ? () => setIsHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setIsHovered(false) : undefined}
@ -133,7 +134,7 @@ export function AnimatedEventCard({
)}
{previews != undefined && (
<div
className="size-full cursor-pointer overflow-hidden rounded md:rounded-lg"
className="size-full cursor-pointer"
onClick={onOpenReview}
onAuxClick={(e) => {
if (e.button === 1) {
@ -145,7 +146,10 @@ export function AnimatedEventCard({
>
{!alertVideos ? (
<img
className="size-full select-none"
className={cn(
"h-full w-auto min-w-10 select-none object-contain",
isSafari && !isLoaded ? "hidden" : "visible",
)}
src={`${apiHost}${event.thumb_path.replace("/media/frigate/", "")}`}
loading={isSafari ? "eager" : "lazy"}
onLoad={() => setIsLoaded(true)}
@ -200,7 +204,14 @@ export function AnimatedEventCard({
</div>
</div>
)}
{!isLoaded && <Skeleton className="absolute inset-0" />}
{!isLoaded && (
<Skeleton
style={{
aspectRatio: alertVideos ? aspectRatio : 16 / 9,
}}
className="size-full"
/>
)}
</div>
</TooltipTrigger>
<TooltipContent>

View File

@ -79,7 +79,7 @@ export default function SearchFilterGroup({
return [...labels].sort();
}, [config, filterList, filter]);
const { data: allSubLabels } = useSWR("sub_labels");
const { data: allSubLabels } = useSWR(["sub_labels", { split_joined: 1 }]);
const allZones = useMemo<string[]>(() => {
if (filterList?.zones) {

View File

@ -14,7 +14,7 @@ export default function ImageLoadingIndicator({
}
return isSafari ? (
<div className={cn("pointer-events-none bg-gray-300", className)} />
<div className={cn("pointer-events-none bg-background_alt", className)} />
) : (
<Skeleton className={cn("pointer-events-none", className)} />
);

View File

@ -77,6 +77,17 @@ export default function ObjectLifecycle({
const [showControls, setShowControls] = useState(false);
const [showZones, setShowZones] = useState(true);
const aspectRatio = useMemo(() => {
if (!config) {
return 16 / 9;
}
return (
config.cameras[event.camera].detect.width /
config.cameras[event.camera].detect.height
);
}, [config, event]);
const getZoneColor = useCallback(
(zoneName: string) => {
const zoneColor =
@ -240,7 +251,15 @@ export default function ObjectLifecycle({
</div>
)}
<div className="relative flex flex-row justify-center">
<div
className={cn(
"relative mx-auto flex max-h-[50dvh] flex-row justify-center",
!imgLoaded && aspectRatio < 16 / 9 && "h-full",
)}
style={{
aspectRatio: !imgLoaded ? aspectRatio : undefined,
}}
>
<ImageLoadingIndicator
className="absolute inset-0"
imgLoaded={imgLoaded}
@ -263,7 +282,7 @@ export default function ObjectLifecycle({
key={event.id}
ref={imgRef}
className={cn(
"max-h-[50dvh] max-w-full select-none rounded-lg object-contain transition-opacity",
"max-h-[50dvh] max-w-full select-none rounded-lg object-contain",
)}
loading={isSafari ? "eager" : "lazy"}
style={
@ -334,7 +353,9 @@ export default function ObjectLifecycle({
/>
</Button>
</TooltipTrigger>
<TooltipContent>Adjust annotation settings</TooltipContent>
<TooltipPortal>
<TooltipContent>Adjust annotation settings</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</div>

View File

@ -37,6 +37,7 @@ import {
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
import { useOverlayState } from "@/hooks/use-overlay-state";
type ReviewDetailDialogProps = {
review?: ReviewSegment;
@ -83,10 +84,15 @@ export default function ReviewDetailDialog({
// dialog and mobile page
const [isOpen, setIsOpen] = useState(review != undefined);
const [isOpen, setIsOpen] = useOverlayState(
"reviewPane",
review != undefined,
);
useEffect(() => {
setIsOpen(review != undefined);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [review]);
const Overlay = isDesktop ? Sheet : MobilePage;
@ -102,7 +108,7 @@ export default function ReviewDetailDialog({
return (
<>
<Overlay
open={isOpen}
open={isOpen ?? false}
onOpenChange={(open) => {
if (!open) {
setReview(undefined);

View File

@ -27,7 +27,13 @@ import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { FaHistory, FaImage, FaRegListAlt, FaVideo } from "react-icons/fa";
import {
FaChevronDown,
FaHistory,
FaImage,
FaRegListAlt,
FaVideo,
} from "react-icons/fa";
import { FaRotate } from "react-icons/fa6";
import ObjectLifecycle from "./ObjectLifecycle";
import {
@ -45,8 +51,14 @@ import {
import { ReviewSegment } from "@/types/review";
import { useNavigate } from "react-router-dom";
import Chip from "@/components/indicators/Chip";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { capitalizeAll } from "@/utils/stringUtil";
import useGlobalMutation from "@/hooks/use-global-mutate";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const SEARCH_TABS = [
"details",
@ -309,33 +321,36 @@ function ObjectDetailsTab({
});
}, [desc, search, mutate]);
const regenerateDescription = useCallback(() => {
if (!search) {
return;
}
const regenerateDescription = useCallback(
(source: "snapshot" | "thumbnails") => {
if (!search) {
return;
}
axios
.put(`events/${search.id}/description/regenerate`)
.then((resp) => {
if (resp.status == 200) {
toast.success(
`A new description has been requested from ${capitalizeFirstLetter(config?.genai.provider ?? "Generative AI")}. Depending on the speed of your provider, the new description may take some time to regenerate.`,
axios
.put(`events/${search.id}/description/regenerate?source=${source}`)
.then((resp) => {
if (resp.status == 200) {
toast.success(
`A new description has been requested from ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")}. Depending on the speed of your provider, the new description may take some time to regenerate.`,
{
position: "top-center",
duration: 7000,
},
);
}
})
.catch(() => {
toast.error(
`Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description`,
{
position: "top-center",
duration: 7000,
},
);
}
})
.catch(() => {
toast.error(
`Failed to call ${capitalizeFirstLetter(config?.genai.provider ?? "Generative AI")} for a new description`,
{
position: "top-center",
},
);
});
}, [search, config]);
});
},
[search, config],
);
return (
<div className="flex flex-col gap-5">
@ -403,7 +418,37 @@ function ObjectDetailsTab({
/>
<div className="flex w-full flex-row justify-end gap-2">
{config?.genai.enabled && (
<Button onClick={regenerateDescription}>Regenerate</Button>
<div className="flex items-center">
<Button
className="rounded-r-none border-r-0"
onClick={() => regenerateDescription("thumbnails")}
>
Regenerate
</Button>
{search.has_snapshot && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="rounded-l-none border-l-0 px-2">
<FaChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => regenerateDescription("snapshot")}
>
Regenerate from Snapshot
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => regenerateDescription("thumbnails")}
>
Regenerate from Thumbnails
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
<Button variant="select" onClick={updateDescription}>
Save

View File

@ -12,7 +12,7 @@ import {
import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isDesktop } from "react-device-detect";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import useSWR from "swr";
@ -62,6 +62,11 @@ export function FrigatePlusDialog({
upload?.plus_id ? "submitted" : "reviewing",
);
useEffect(
() => setState(upload?.plus_id ? "submitted" : "reviewing"),
[upload],
);
const onSubmitToPlus = useCallback(
async (falsePositive: boolean) => {
if (!upload) {

View File

@ -14,7 +14,8 @@ export default function useKeyboardListener(
) {
const keyDownListener = useCallback(
(e: KeyboardEvent) => {
if (!e) {
// @ts-expect-error we know this field exists
if (!e || e.target.tagName == "INPUT") {
return;
}

View File

@ -1,4 +1,5 @@
import { useFullscreen } from "@/hooks/use-fullscreen";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import {
useHashState,
usePersistedOverlayState,
@ -43,6 +44,18 @@ function Live() {
const { fullscreen, toggleFullscreen, supportsFullScreen } =
useFullscreen(mainRef);
useKeyboardListener(["f"], (key, modifiers) => {
if (!modifiers.down) {
return;
}
switch (key) {
case "f":
toggleFullscreen();
break;
}
});
// document title
useEffect(() => {

View File

@ -1,3 +1,10 @@
export const capitalizeFirstLetter = (text: string): string => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
export const capitalizeAll = (text: string): string => {
return text
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};

View File

@ -611,23 +611,40 @@ function DetectionReview({
// keyboard
useKeyboardListener(["a", "r"], (key, modifiers) => {
useKeyboardListener(["a", "r", "PageDown", "PageUp"], (key, modifiers) => {
if (modifiers.repeat || !modifiers.down) {
return;
}
if (key == "a" && modifiers.ctrl) {
onSelectAllReviews();
}
if (key == "r" && selectedReviews.length > 0) {
currentItems?.forEach((item) => {
if (selectedReviews.includes(item.id)) {
item.has_been_reviewed = true;
markItemAsReviewed(item);
switch (key) {
case "a":
if (modifiers.ctrl) {
onSelectAllReviews();
}
});
setSelectedReviews([]);
break;
case "r":
if (selectedReviews.length > 0) {
currentItems?.forEach((item) => {
if (selectedReviews.includes(item.id)) {
item.has_been_reviewed = true;
markItemAsReviewed(item);
}
});
setSelectedReviews([]);
}
break;
case "PageDown":
contentRef.current?.scrollBy({
top: contentRef.current.clientHeight / 2,
behavior: "smooth",
});
break;
case "PageUp":
contentRef.current?.scrollBy({
top: -contentRef.current.clientHeight / 2,
behavior: "smooth",
});
break;
}
});

View File

@ -35,7 +35,12 @@ export default function ExploreView({
// data
const { data: events, mutate } = useSWR<SearchResult[]>(
const {
data: events,
mutate,
isLoading,
isValidating,
} = useSWR<SearchResult[]>(
[
"events/explore",
{
@ -81,7 +86,7 @@ export default function ExploreView({
}
}, [events, searchDetail, setSearchDetail]);
if (!events) {
if (isLoading) {
return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
);
@ -93,6 +98,7 @@ export default function ExploreView({
<ThumbnailRow
key={label}
searchResults={filteredEvents}
isValidating={isValidating}
objectType={label}
setSearchDetail={setSearchDetail}
/>
@ -104,12 +110,14 @@ export default function ExploreView({
type ThumbnailRowType = {
objectType: string;
searchResults?: SearchResult[];
isValidating: boolean;
setSearchDetail: (search: SearchResult | undefined) => void;
};
function ThumbnailRow({
objectType,
searchResults,
isValidating,
setSearchDetail,
}: ThumbnailRowType) {
const navigate = useNavigate();
@ -123,7 +131,7 @@ function ThumbnailRow({
return (
<div className="rounded-lg bg-background_alt p-2 md:px-4">
<div className="text-lg capitalize">
<div className="flex flex-row items-center text-lg capitalize">
{objectType.replaceAll("_", " ")}
{searchResults && (
<span className="ml-3 text-sm text-secondary-foreground">
@ -135,6 +143,7 @@ function ThumbnailRow({
tracked objects){" "}
</span>
)}
{isValidating && <ActivityIndicator className="ml-2 size-4" />}
</div>
<div className="flex flex-row items-center space-x-2 py-2">
{searchResults?.map((event) => (
@ -191,7 +200,7 @@ function ExploreThumbnailImage({
<img
ref={imgRef}
className={cn(
"absolute h-full w-full cursor-pointer rounded-lg object-cover transition-all duration-300 ease-in-out md:rounded-2xl",
"absolute h-full w-full cursor-pointer rounded-lg object-cover transition-all duration-300 ease-in-out lg:rounded-2xl",
)}
style={
isIOS

View File

@ -236,6 +236,25 @@ export default function LiveCameraView({
return "mse";
}, [lowBandwidth, mic, webRTC, isRestreamed]);
useKeyboardListener(["m"], (key, modifiers) => {
if (!modifiers.down) {
return;
}
switch (key) {
case "m":
if (supportsAudioOutput) {
setAudio(!audio);
}
break;
case "t":
if (supports2WayTalk) {
setMic(!mic);
}
break;
}
});
// layout state
const windowAspectRatio = useMemo(() => {

View File

@ -13,8 +13,8 @@ import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import { SearchFilter, SearchResult, SearchSource } from "@/types/search";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { isMobileOnly } from "react-device-detect";
import { LuImage, LuSearchX, LuText } from "react-icons/lu";
import { isDesktop, isMobileOnly } from "react-device-detect";
import { LuColumns, LuImage, LuSearchX, LuText } from "react-icons/lu";
import useSWR from "swr";
import ExploreView from "../explore/ExploreView";
import useKeyboardListener, {
@ -26,6 +26,13 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { Slider } from "@/components/ui/slider";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { usePersistence } from "@/hooks/use-persistence";
type SearchViewProps = {
search: string;
@ -53,10 +60,26 @@ export default function SearchView({
loadMore,
hasMore,
}: SearchViewProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
// grid
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
const effectiveColumnCount = useMemo(() => columnCount ?? 4, [columnCount]);
const gridClassName = cn("grid w-full gap-2 px-1 gap-2 lg:gap-4 md:mx-2", {
"sm:grid-cols-2": effectiveColumnCount <= 2,
"sm:grid-cols-3": effectiveColumnCount === 3,
"sm:grid-cols-4": effectiveColumnCount === 4,
"sm:grid-cols-5": effectiveColumnCount === 5,
"sm:grid-cols-6": effectiveColumnCount === 6,
"sm:grid-cols-7": effectiveColumnCount === 7,
"sm:grid-cols-8": effectiveColumnCount >= 8,
});
// suggestions values
const allLabels = useMemo<string[]>(() => {
@ -217,13 +240,25 @@ export default function SearchView({
return newIndex;
});
break;
case "PageDown":
contentRef.current?.scrollBy({
top: contentRef.current.clientHeight / 2,
behavior: "smooth",
});
break;
case "PageUp":
contentRef.current?.scrollBy({
top: -contentRef.current.clientHeight / 2,
behavior: "smooth",
});
break;
}
},
[uniqueResults, inputFocused],
);
useKeyboardListener(
["ArrowLeft", "ArrowRight"],
["ArrowLeft", "ArrowRight", "PageDown", "PageUp"],
onKeyboardShortcut,
!inputFocused,
);
@ -324,7 +359,10 @@ export default function SearchView({
)}
</div>
<div className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto">
<div
ref={contentRef}
className="no-scrollbar flex flex-1 flex-wrap content-start gap-2 overflow-y-auto"
>
{uniqueResults?.length == 0 && !isLoading && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuSearchX className="size-16" />
@ -340,7 +378,7 @@ export default function SearchView({
)}
{uniqueResults && (
<div className="grid w-full gap-2 px-1 sm:grid-cols-2 md:mx-2 md:grid-cols-4 md:gap-4 3xl:grid-cols-6">
<div className={gridClassName}>
{uniqueResults &&
uniqueResults.map((value, index) => {
const selected = selectedIndex === index;
@ -409,6 +447,47 @@ export default function SearchView({
<div className="flex h-12 w-full justify-center">
{hasMore && isLoading && <ActivityIndicator />}
</div>
{isDesktop && columnCount && (
<div
className={cn(
"fixed bottom-12 right-3 z-50 flex flex-row gap-2 lg:bottom-9",
)}
>
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<div className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-75 transition-all duration-300 hover:bg-muted hover:opacity-100">
<LuColumns className="size-5 md:m-[6px]" />
</div>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Adjust Grid Columns</TooltipContent>
</Tooltip>
<PopoverContent className="mr-2 w-80">
<div className="space-y-4">
<div className="font-medium leading-none">
Grid Columns
</div>
<div className="flex items-center space-x-4">
<Slider
value={[effectiveColumnCount]}
onValueChange={([value]) => setColumnCount(value)}
max={8}
min={2}
step={1}
className="flex-grow"
/>
<span className="w-9 text-center text-sm font-medium">
{effectiveColumnCount}
</span>
</div>
</div>
</PopoverContent>
</Popover>
</div>
)}
</>
)}
</div>