Compare commits

...
27 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub 416a9b7692 Validate preview filename and camera access (#22530)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* validate preview filename and camera access

* correctly handle camera names with dashes
2026-03-19 09:42:33 -05:00
Josh HawkinsandGitHub d11c26970d Fix cross-camera auth in timeline and media endpoints (#22522)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* Fix cross-camera authorization bypass in timeline and event media endpoints

* formatting
2026-03-18 18:54:31 -06:00
Josh HawkinsandGitHub e78da2758d Restrict /api/config/raw to admin role to prevent credential leak to non-admin users (#22523) 2026-03-18 16:14:59 -06:00
GuoQing LiuandGitHub ae9b307dfc docs: remove onvif host environment variable (#22517)
* docs: remove onvif host environment variable

* Update ONVIF configuration notes in reference.md
2026-03-18 09:54:26 -05:00
Josh HawkinsandGitHub 01c16a9250 check for config update before state evaluation (#22495)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
2026-03-16 17:23:36 -05:00
Josh HawkinsandGitHub d4731c1dea don't try to run cleanup if frigate is in safe mode (#22492) 2026-03-16 13:38:24 -06:00
65ca90db20 Add Strix to third party extensions (#22488)
Co-authored-by: eduard256 <ceo@webaweba.com>
2026-03-16 12:46:21 -05:00
Josh HawkinsandGitHub 3ec2305e6a sync Tracking Details timeline with keyframe-snapped vod clip start (#22454)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
2026-03-15 12:21:42 -06:00
Josh HawkinsandGitHub 8b035be132 disable pip for animated event cards (#22438)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
2026-03-14 16:44:16 -06:00
Josh HawkinsandGitHub be79ad89b6 hide set password menu option when native auth is disabled (#22439) 2026-03-14 15:35:48 -05:00
Josh HawkinsandGitHub b147b53522 add padding to dropdown text (#22420)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
2026-03-13 09:43:07 -05:00
leccelecceandGitHub d2b2faa2d7 Update dev contrib docs with Python checks (#22419) 2026-03-13 08:16:10 -06:00
Josh HawkinsandGitHub 614a6b39d4 consistently sort class names (#22415)
keep None at the bottom
2026-03-13 08:05:56 -05:00
Nicolas MowenandGitHub f29ee53fb4 Add handler for license plate which is not expected to be stationary (#22416) 2026-03-13 07:02:42 -06:00
Josh HawkinsandGitHub 544d3c6139 keep nav buttons visible (#22384)
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
nav buttons would be hidden when closing and reopening dialog after selecting the tracking details pane
2026-03-11 08:27:10 -06:00
Josh HawkinsandGitHub 104e623923 Filter push notifications by user role camera access (#22385)
* filter push notifications by user camera access with cached role resolution

* simplify
2026-03-11 08:26:09 -06:00
Nicolas MowenandGitHub 59fc8449ed Various Fixes (#22376)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
* Correctly send topic with role value

* Fix missing previews

* Catch other one
2026-03-10 14:26:45 -05:00
GuoQing LiuandGitHub 19480867fb docs: add highlight magic comments (#22367) 2026-03-10 07:18:02 -06:00
Josh HawkinsandGitHub 1188d87588 Save detect dimensions to config on add camera wizard save (#22349)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
* add util for optimal detect resolution

* add detect to type

* save optimal detect resolution to config on wizard save

* use const
2026-03-09 15:50:46 -06:00
Josh HawkinsandGitHub c4a5ac0e77 fix go2rtc homekit handling (#22346)
file needs to be blank if not using homekit, not {}
it seems like go2rtc is not parsing {} as yaml
2026-03-09 10:33:14 -05:00
Josh HawkinsandGitHub 41e290449e Environment variable fixes (#22335)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
* fix environment_vars timing bug for EnvString substitution

FRIGATE_ENV_VARS is populated at module import time but was never
updated when environment_vars config section set new vars into
os.environ. This meant HA OS users setting FRIGATE_* vars via
environment_vars could not use them in EnvString fields.

* add EnvString support to MQTT and ONVIF host fields

* add tests

* docs

* update reference config
2026-03-08 16:08:40 -05:00
Josh HawkinsandGitHub b6f78bd1f2 fix thumbnail encoding logic (#22329) 2026-03-08 14:15:12 -06:00
Josh HawkinsandGitHub bde518e861 Fix preview retrieval to handle missing previews gracefully (#22331) 2026-03-08 14:14:44 -06:00
ARandomGitHubUserandGitHub 4e71a835cb Fix broken link to Home Assistant apps page (#22320)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Co-authored-by: ARandomGitHubUser <7754708+ARandomGitHubUser@users.noreply.github.com>
2026-03-08 08:00:21 -06:00
RokiandGitHub 537e723c30 Fix/rknn arcface input format master (#22319)
* "fix: correct ArcFace input format for RKNN runner"

* ruff format
2026-03-08 07:00:06 -06:00
Josh HawkinsandGitHub f316244495 Improve playback of videos in Tracking Details (#22301)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
* prevent short hls segments by extending clip backwards

* clean up

* snap to keyframe instead of arbitrarily subtracting time

* formatting
2026-03-07 06:36:08 -07:00
Josh HawkinsandGitHub d1f3a807d3 call out avx2 requirement (#22305) 2026-03-07 06:18:17 -07:00
62 changed files with 804 additions and 260 deletions
@@ -55,7 +55,7 @@ function setup_homekit_config() {
if [[ ! -f "${config_path}" ]]; then
echo "[INFO] Creating empty config file for HomeKit..."
echo '{}' > "${config_path}"
: > "${config_path}"
fi
# Convert YAML to JSON for jq processing
@@ -65,23 +65,25 @@ function setup_homekit_config() {
return 0
}
# Use jq to filter and keep only the homekit section
local cleaned_json="/tmp/cache/homekit_cleaned.json"
jq '
# Keep only the homekit section if it exists, otherwise empty object
if has("homekit") then {homekit: .homekit} else {} end
' "${temp_json}" > "${cleaned_json}" 2>/dev/null || {
echo '{}' > "${cleaned_json}"
}
# Use jq to extract the homekit section, if it exists
local homekit_json
homekit_json=$(jq '
if has("homekit") then {homekit: .homekit} else null end
' "${temp_json}" 2>/dev/null) || homekit_json="null"
# Convert back to YAML and write to the config file
yq eval -P "${cleaned_json}" > "${config_path}" 2>/dev/null || {
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
echo '{}' > "${config_path}"
}
# If no homekit section, write an empty config file
if [[ "${homekit_json}" == "null" ]]; then
: > "${config_path}"
else
# Convert homekit JSON back to YAML and write to the config file
echo "${homekit_json}" | yq eval -P - > "${config_path}" 2>/dev/null || {
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
: > "${config_path}"
}
fi
# Clean up temp files
rm -f "${temp_json}" "${cleaned_json}"
rm -f "${temp_json}"
}
set_libva_version
+10 -2
View File
@@ -44,13 +44,21 @@ go2rtc:
### `environment_vars`
This section can be used to set environment variables for those unable to modify the environment of the container, like within Home Assistant OS.
This section can be used to set environment variables for those unable to modify the environment of the container, like within Home Assistant OS. Docker users should set environment variables in their `docker run` command (`-e FRIGATE_MQTT_PASSWORD=secret`) or `docker-compose.yml` file (`environment:` section) instead. Note that values set here are stored in plain text in your config file, so if the goal is to keep credentials out of your configuration, use Docker environment variables or Docker secrets instead.
Variables prefixed with `FRIGATE_` can be referenced in config fields that support environment variable substitution (such as MQTT host and credentials, camera stream URLs, and ONVIF host and credentials) using the `{FRIGATE_VARIABLE_NAME}` syntax.
Example:
```yaml
environment_vars:
VARIABLE_NAME: variable_value
FRIGATE_MQTT_USER: my_mqtt_user
FRIGATE_MQTT_PASSWORD: my_mqtt_password
mqtt:
host: "{FRIGATE_MQTT_HOST}"
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
```
#### TensorFlow Thread Configuration
+1 -1
View File
@@ -232,7 +232,7 @@ The viewer role provides read-only access to all cameras in the UI and API. Cust
### Role Configuration Example
```yaml
```yaml {11-16}
cameras:
front_door:
# ... camera config
+6 -3
View File
@@ -24,7 +24,7 @@ A custom icon can be added to the birdseye background by providing a 180x180 ima
If you want to include a camera in Birdseye view only for specific circumstances, or just don't include it at all, the Birdseye setting can be set at the camera level.
```yaml
```yaml {8-10,12-14}
# Include all cameras by default in Birdseye view
birdseye:
enabled: True
@@ -48,6 +48,7 @@ By default birdseye shows all cameras that have had the configured activity in t
```yaml
birdseye:
enabled: True
# highlight-next-line
inactivity_threshold: 15
```
@@ -78,9 +79,11 @@ birdseye:
cameras:
front:
birdseye:
# highlight-next-line
order: 1
back:
birdseye:
# highlight-next-line
order: 2
```
@@ -92,7 +95,7 @@ It is possible to limit the number of cameras shown on birdseye at one time. Whe
For example, this can be configured to only show the most recently active camera.
```yaml
```yaml {3-4}
birdseye:
enabled: True
layout:
@@ -103,7 +106,7 @@ birdseye:
By default birdseye tries to fit 2 cameras in each row and then double in size until a suitable layout is found. The scaling can be configured with a value between 1.0 and 5.0 depending on use case.
```yaml
```yaml {3-4}
birdseye:
enabled: True
layout:
+4 -2
View File
@@ -23,6 +23,7 @@ Some cameras support h265 with different formats, but Safari only supports the a
cameras:
h265_cam: # <------ Doesn't matter what the camera is called
ffmpeg:
# highlight-next-line
apple_compatibility: true # <- Adds compatibility with MacOS and iPhone
```
@@ -30,7 +31,7 @@ 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.
```yaml
```yaml {3,10}
go2rtc:
streams:
mjpeg_cam: "ffmpeg:http://your_mjpeg_stream_url#video=h264#hardware" # <- use hardware acceleration to create an h264 stream usable for other components.
@@ -96,6 +97,7 @@ This camera is H.265 only. To be able to play clips on some devices (like MacOs
cameras:
annkec800: # <------ Name the camera
ffmpeg:
# highlight-next-line
apple_compatibility: true # <- Adds compatibility with MacOS and iPhone
output_args:
record: preset-record-generic-audio-aac
@@ -274,7 +276,7 @@ To use a USB camera (webcam) with Frigate, the recommendation is to use go2rtc's
- In your Frigate Configuration File, add the go2rtc stream and roles as appropriate:
```
```yaml {4,11-12}
go2rtc:
streams:
usb_camera:
+1 -1
View File
@@ -66,7 +66,7 @@ Not every PTZ supports ONVIF, which is the standard protocol Frigate uses to com
Add the onvif section to your camera in your configuration file:
```yaml
```yaml {4-8}
cameras:
back:
ffmpeg: ...
@@ -11,7 +11,7 @@ Object classification models are lightweight and run very fast on CPU.
Training the model does briefly use a high amount of system resources for about 13 minutes per training run. On lower-power devices, training may take longer.
A CPU with AVX instructions is required for training and inference.
A CPU with AVX + AVX2 instructions is required for training and inference.
## Classes
@@ -27,7 +27,6 @@ For object classification:
### Classification Type
- **Sub label**:
- Applied to the objects `sub_label` field.
- Ideal for a single, more specific identity or type.
- Example: `cat``Leo`, `Charlie`, `None`.
@@ -119,6 +118,7 @@ Enable debug logs for classification models by adding `frigate.data_processing.r
logger:
default: info
logs:
# highlight-next-line
frigate.data_processing.real_time.custom_classification: debug
```
@@ -11,7 +11,7 @@ State classification models are lightweight and run very fast on CPU.
Training the model does briefly use a high amount of system resources for about 13 minutes per training run. On lower-power devices, training may take longer.
A CPU with AVX instructions is required for training and inference.
A CPU with AVX + AVX2 instructions is required for training and inference.
## Classes
@@ -85,6 +85,7 @@ Enable debug logs for classification models by adding `frigate.data_processing.r
logger:
default: info
logs:
# highlight-next-line
frigate.data_processing.real_time.custom_classification: debug
```
+1 -4
View File
@@ -32,7 +32,7 @@ All of these features run locally on your system.
## Minimum System Requirements
A CPU with AVX instructions is required to run Face Recognition.
A CPU with AVX + AVX2 instructions is required to run Face Recognition.
The `small` model is optimized for efficiency and runs on the CPU, most CPUs should run the model efficiently.
@@ -145,17 +145,14 @@ Start with the [Usage](#usage) section and re-read the [Model Requirements](#mod
1. Ensure `person` is being _detected_. A `person` will automatically be scanned by Frigate for a face. Any detected faces will appear in the Recent Recognitions tab in the Frigate UI's Face Library.
If you are using a Frigate+ or `face` detecting model:
- Watch the debug view (Settings --> Debug) to ensure that `face` is being detected along with `person`.
- You may need to adjust the `min_score` for the `face` object if faces are not being detected.
If you are **not** using a Frigate+ or `face` detecting model:
- Check your `detect` stream resolution and ensure it is sufficiently high enough to capture face details on `person` objects.
- You may need to lower your `detection_threshold` if faces are not being detected.
2. Any detected faces will then be _recognized_.
- Make sure you have trained at least one face per the recommendations above.
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
+2 -2
View File
@@ -109,7 +109,7 @@ genai:
To use a different Gemini-compatible API endpoint, set the `provider_options` with the `base_url` key to your provider's API URL. For example:
```
```yaml {4,5}
genai:
provider: gemini
...
@@ -152,7 +152,7 @@ To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` env
For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`:
```yaml
```yaml {5,6}
genai:
provider: openai
base_url: http://your-llama-server
@@ -80,6 +80,7 @@ By default, review summaries use preview images (cached preview frames) which ha
review:
genai:
enabled: true
# highlight-next-line
image_source: recordings # Options: "preview" (default) or "recordings"
```
@@ -104,7 +105,7 @@ If recordings are not available for a given time period, the system will automat
Along with the concern of suspicious activity or immediate threat, you may have concerns such as animals in your garden or a gate being left open. These concerns can be configured so that the review summaries will make note of them if the activity requires additional review. For example:
```yaml
```yaml {4,5}
review:
genai:
enabled: true
@@ -116,7 +117,7 @@ review:
By default, review summaries are generated in English. You can configure Frigate to generate summaries in your preferred language by setting the `preferred_language` option:
```yaml
```yaml {4}
review:
genai:
enabled: true
@@ -117,12 +117,13 @@ services:
frigate:
...
image: ghcr.io/blakeblackshear/frigate:stable
# highlight-next-line
privileged: true
```
##### Docker Run CLI - Privileged
```bash
```bash {4}
docker run -d \
--name frigate \
...
@@ -136,7 +137,7 @@ Only recent versions of Docker support the `CAP_PERFMON` capability. You can tes
##### Docker Compose - CAP_PERFMON
```yaml
```yaml {5,6}
services:
frigate:
...
@@ -147,7 +148,7 @@ services:
##### Docker Run CLI - CAP_PERFMON
```bash
```bash {4}
docker run -d \
--name frigate \
...
@@ -214,7 +215,7 @@ Additional configuration is needed for the Docker container to be able to access
#### Docker Compose - Nvidia GPU
```yaml
```yaml {5-12}
services:
frigate:
...
@@ -231,7 +232,7 @@ services:
#### Docker Run CLI - Nvidia GPU
```bash
```bash {4}
docker run -d \
--name frigate \
...
@@ -310,7 +311,7 @@ ffmpeg:
If running Frigate through Docker, you either need to run in privileged mode or
map the `/dev/video*` devices to Frigate. With Docker Compose add:
```yaml
```yaml {4-5}
services:
frigate:
...
@@ -320,7 +321,7 @@ services:
Or with `docker run`:
```bash
```bash {4}
docker run -d \
--name frigate \
...
@@ -352,7 +353,7 @@ You will need to use the image with the nvidia container runtime:
### Docker Run CLI - Jetson
```bash
```bash {3}
docker run -d \
...
--runtime nvidia
@@ -361,7 +362,7 @@ docker run -d \
### Docker Compose - Jetson
```yaml
```yaml {5}
services:
frigate:
...
@@ -452,14 +453,14 @@ Restarting ffmpeg...
you should try to uprade to FFmpeg 7. This can be done using this config option:
```
```yaml
ffmpeg:
path: "7.0"
```
You can set this option globally to use FFmpeg 7 for all cameras or on camera level to use it only for specific cameras. Do not confuse this option with:
```
```yaml
cameras:
name:
ffmpeg:
@@ -481,7 +482,7 @@ Make sure to follow the [Synaptics specific installation instructions](/frigate/
Add one of the following FFmpeg presets to your `config.yml` to enable hardware video processing:
```yaml
```yaml {2}
ffmpeg:
hwaccel_args: -c:v h264_v4l2m2m
input_args: preset-rtsp-restream
+2 -1
View File
@@ -50,6 +50,7 @@ Frigate supports the use of environment variables starting with `FRIGATE_` **onl
```yaml
mqtt:
host: "{FRIGATE_MQTT_HOST}"
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
```
@@ -60,7 +61,7 @@ mqtt:
```yaml
onvif:
host: 10.0.10.10
host: "192.168.1.12"
port: 8000
user: "{FRIGATE_RTSP_USER}"
password: "{FRIGATE_RTSP_PASSWORD}"
@@ -30,7 +30,7 @@ In the default mode, Frigate's LPR needs to first detect a `car` or `motorcycle`
## Minimum System Requirements
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM and a CPU with AVX instructions is required.
License plate recognition works by running AI models locally on your system. The YOLOv9 plate detector model and the OCR models ([PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR)) are relatively lightweight and can run on your CPU or GPU, depending on your configuration. At least 4GB of RAM and a CPU with AVX + AVX2 instructions is required.
## Configuration
@@ -43,7 +43,7 @@ lpr:
Like other enrichments in Frigate, LPR **must be enabled globally** to use the feature. You should disable it for specific cameras at the camera level if you don't want to run LPR on cars on those cameras:
```yaml
```yaml {4,5}
cameras:
garage:
...
@@ -375,7 +375,6 @@ Use `match_distance` to allow small character mismatches. Alternatively, define
Start with ["Why isn't my license plate being detected and recognized?"](#why-isnt-my-license-plate-being-detected-and-recognized). If you are still having issues, work through these steps.
1. Start with a simplified LPR config.
- Remove or comment out everything in your LPR config, including `min_area`, `min_plate_length`, `format`, `known_plates`, or `enhancement` values so that the only values left are `enabled` and `debug_save_plates`. This will run LPR with Frigate's default values.
```yaml
@@ -386,31 +385,28 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
```
2. Enable debug logs to see exactly what Frigate is doing.
- Enable debug logs for LPR by adding `frigate.data_processing.common.license_plate: debug` to your `logger` configuration. These logs are _very_ verbose, so only keep this enabled when necessary. Restart Frigate after this change.
```yaml
logger:
default: info
logs:
# highlight-next-line
frigate.data_processing.common.license_plate: debug
```
3. Ensure your plates are being _detected_.
If you are using a Frigate+ or `license_plate` detecting model:
- Watch the debug view (Settings --> Debug) to ensure that `license_plate` is being detected.
- View MQTT messages for `frigate/events` to verify detected plates.
- You may need to adjust your `min_score` and/or `threshold` for the `license_plate` object if your plates are not being detected.
If you are **not** using a Frigate+ or `license_plate` detecting model:
- Watch the debug logs for messages from the YOLOv9 plate detector.
- You may need to adjust your `detection_threshold` if your plates are not being detected.
4. Ensure the characters on detected plates are being _recognized_.
- Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear.
- Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the `car` or `motorcycle` label will change to the recognized plate when LPR is enabled and working.
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
+3 -3
View File
@@ -77,7 +77,7 @@ Configure the `streams` option with a "friendly name" for your stream followed b
Using Frigate's internal version of go2rtc is required to use this feature. You cannot specify paths in the `streams` configuration, only go2rtc stream names.
```yaml
```yaml {3,6,8,25-29}
go2rtc:
streams:
test_cam:
@@ -116,7 +116,7 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req
- For external access, over the internet, setup your router to forward port `8555` to port `8555` on the Frigate device, for both TCP and UDP.
- For internal/local access, unless you are running through the HA App, you will also need to set the WebRTC candidates list in the go2rtc config. For example, if `192.168.1.10` is the local IP of the device running Frigate:
```yaml title="config.yml"
```yaml title="config.yml" {4-7}
go2rtc:
streams:
test_cam: ...
@@ -154,7 +154,7 @@ If not running in host mode, port 8555 will need to be mapped for the container:
docker-compose.yml
```yaml
```yaml {4-6}
services:
frigate:
...
+2 -2
View File
@@ -572,7 +572,7 @@ $ docker run --device=/dev/kfd --device=/dev/dri \
When using Docker Compose:
```yaml
```yaml {4-6}
services:
frigate:
...
@@ -603,7 +603,7 @@ $ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \
When using Docker Compose:
```yaml
```yaml {4-5}
services:
frigate:
...
+1 -1
View File
@@ -130,7 +130,7 @@ When exporting a time-lapse the default speed-up is 25x with 30 FPS. This means
To configure the speed-up factor, the frame rate and further custom settings, the configuration parameter `timelapse_args` can be used. The below configuration example would change the time-lapse speed to 60x (for fitting 1 hour of recording into 1 minute of time-lapse) with 25 FPS:
```yaml
```yaml {3-4}
record:
enabled: True
export:
+4
View File
@@ -16,6 +16,8 @@ mqtt:
# Optional: Enable mqtt server (default: shown below)
enabled: True
# Required: host name
# NOTE: MQTT host can be specified with an environment variable or docker secrets that must begin with 'FRIGATE_'.
# e.g. host: '{FRIGATE_MQTT_HOST}'
host: mqtt.server.com
# Optional: port (default: shown below)
port: 1883
@@ -906,6 +908,8 @@ cameras:
onvif:
# Required: host of the camera being connected to.
# NOTE: HTTP is assumed by default; HTTPS is supported if you specify the scheme, ex: "https://0.0.0.0".
# NOTE: ONVIF user, and password can be specified with environment variables or docker secrets
# that must begin with 'FRIGATE_'. e.g. host: '{FRIGATE_ONVIF_USERNAME}'
host: 0.0.0.0
# Optional: ONVIF port for device (default: shown below).
port: 8000
+3 -1
View File
@@ -34,7 +34,7 @@ To improve connection speed when using Birdseye via restream you can enable a sm
The go2rtc restream can be secured with RTSP based username / password authentication. Ex:
```yaml
```yaml {2-4}
go2rtc:
rtsp:
username: "admin"
@@ -147,6 +147,7 @@ For example:
```yaml
go2rtc:
streams:
# highlight-error-line
my_camera: rtsp://username:$@foo%@192.168.1.100
```
@@ -155,6 +156,7 @@ becomes
```yaml
go2rtc:
streams:
# highlight-next-line
my_camera: rtsp://username:$%40foo%25@192.168.1.100
```
+1 -1
View File
@@ -71,7 +71,7 @@ To exclude a specific camera from alerts or detections, simply provide an empty
For example, to exclude objects on the camera _gatecamera_ from any detections, include this in your config:
```yaml
```yaml {3-5}
cameras:
gatecamera:
review:
+1 -1
View File
@@ -13,7 +13,7 @@ Semantic Search is accessed via the _Explore_ view in the Frigate UI.
Semantic Search works by running a large AI model locally on your system. Small or underpowered systems like a Raspberry Pi will not run Semantic Search reliably or at all.
A minimum of 8GB of RAM is required to use Semantic Search. A CPU with AVX instructions is required to run Semantic Search. A GPU is not strictly required but will provide a significant performance increase over CPU-only systems.
A minimum of 8GB of RAM is required to use Semantic Search. A CPU with AVX + AVX2 instructions is required to run Semantic Search. A GPU is not strictly required but will provide a significant performance increase over CPU-only systems.
For best performance, 16GB or more of RAM and a dedicated GPU are recommended.
+3 -3
View File
@@ -20,7 +20,7 @@ tls:
TLS certificates can be mounted at `/etc/letsencrypt/live/frigate` using a bind mount or docker volume.
```yaml
```yaml {3-4}
frigate:
...
volumes:
@@ -32,7 +32,7 @@ Within the folder, the private key is expected to be named `privkey.pem` and the
Note that certbot uses symlinks, and those can't be followed by the container unless it has access to the targets as well, so if using certbot you'll also have to mount the `archive` folder for your domain, e.g.:
```yaml
```yaml {3-5}
frigate:
...
volumes:
@@ -46,7 +46,7 @@ Frigate automatically compares the fingerprint of the certificate at `/etc/letse
If you issue Frigate valid certificates you will likely want to configure it to run on port 443 so you can access it without a port number like `https://your-frigate-domain.com` by mapping 8971 to 443.
```yaml
```yaml {3-4}
frigate:
...
ports:
+5 -1
View File
@@ -18,7 +18,7 @@ To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the
Often you will only want alerts to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to have an alert created when an object enters your entire_yard zone, the config would be:
```yaml
```yaml {6,8}
cameras:
name_of_your_camera:
review:
@@ -104,6 +104,7 @@ cameras:
name_of_your_camera:
zones:
sidewalk:
# highlight-next-line
loitering_time: 4 # unit is in seconds
objects:
- person
@@ -118,6 +119,7 @@ cameras:
name_of_your_camera:
zones:
front_yard:
# highlight-next-line
inertia: 3
objects:
- person
@@ -130,6 +132,7 @@ cameras:
name_of_your_camera:
zones:
driveway_entrance:
# highlight-next-line
inertia: 1
objects:
- car
@@ -192,5 +195,6 @@ cameras:
coordinates: ...
distances: ...
inertia: 1
# highlight-next-line
speed_threshold: 20 # unit is in kph or mph, depending on how unit_system is set (see above)
```
+30
View File
@@ -89,6 +89,14 @@ After closing VS Code, you may still have containers running. To close everythin
### Testing
#### Unit Tests
GitHub will execute unit tests on new PRs. You must ensure that all tests pass.
```shell
python3 -u -m unittest
```
#### FFMPEG Hardware Acceleration
The following commands are used inside the container to ensure hardware acceleration is working properly.
@@ -125,6 +133,28 @@ ffmpeg -hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format
ffmpeg -c:v h264_qsv -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null
```
### Submitting a pull request
Code must be formatted, linted and type-tested. GitHub will run these checks on pull requests, so it is advised to run them yourself prior to opening.
**Formatting**
```shell
ruff format frigate migrations docker *.py
```
**Linting**
```shell
ruff check frigate migrations docker *.py
```
**MyPy Static Typing**
```shell
python3 -u -m mypy --config-file frigate/mypy.ini frigate
```
## Web Interface
### Prerequisites
+1 -1
View File
@@ -26,7 +26,7 @@ I may earn a small commission for my endorsement, recommendation, testimonial, o
## Server
My current favorite is the Beelink EQ13 because of the efficient N100 CPU and dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU (with AVX instructions) and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral, Hailo, or other AI accelerators.
My current favorite is the Beelink EQ13 because of the efficient N100 CPU and dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU (with AVX + AVX2 instructions) and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral, Hailo, or other AI accelerators.
Note that many of these mini PCs come with Windows pre-installed, and you will need to install Linux according to the [getting started guide](../guides/getting_started.md).
+1 -1
View File
@@ -3,7 +3,7 @@ id: installation
title: Installation
---
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/addons/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
:::tip
+1 -1
View File
@@ -36,7 +36,7 @@ There are many different hardware options for object detection depending on prio
### CPU
Frigate requires a CPU with AVX instructions. Most modern CPUs (post-2011) support AVX, but it is generally absent in low-power or budget-oriented processors, particularly older Intel Pentium, Celeron, and Atom-based chips. Specifically, Intel Celeron and Pentium models prior to the 2020 Tiger Lake generation typically lack AVX.
Frigate requires a CPU with AVX + AVX2 instructions. Most modern CPUs (post-2011) support AVX and AVX2, but it is generally absent in low-power or budget-oriented processors, particularly older Intel Pentium, Celeron, and Atom-based chips. Specifically, Intel Celeron and Pentium models prior to the 2020 Tiger Lake generation typically lack AVX. Older Intel Xeon models may have AVX, but may lack AVX2.
### Storage
+7 -6
View File
@@ -150,7 +150,7 @@ Here is an example configuration with hardware acceleration configured to work w
`docker-compose.yml` (after modifying, you will need to run `docker compose up -d` to apply changes)
```yaml
```yaml {4,5}
services:
frigate:
...
@@ -168,6 +168,7 @@ cameras:
name_of_your_camera:
ffmpeg:
inputs: ...
# highlight-next-line
hwaccel_args: preset-vaapi
detect: ...
```
@@ -183,7 +184,7 @@ In many cases, the integrated graphics on Intel CPUs provides sufficient perform
You need to refer to **Configure hardware acceleration** above to enable the container to use the GPU.
```yaml
```yaml {3-6,9-15,20-21}
mqtt: ...
detectors: # <---- add detectors
@@ -217,7 +218,7 @@ If you have a USB Coral, you will need to add a detectors section to your config
`docker-compose.yml` (after modifying, you will need to run `docker compose up -d` to apply changes)
```yaml
```yaml {4-6}
services:
frigate:
...
@@ -227,7 +228,7 @@ services:
...
```
```yaml
```yaml {3-6,11-12}
mqtt: ...
detectors: # <---- add detectors
@@ -263,7 +264,7 @@ Note that motion masks should not be used to mark out areas where you do not wan
Your configuration should look similar to this now.
```yaml
```yaml {16-18}
mqtt:
enabled: False
@@ -290,7 +291,7 @@ In order to review activity in the Frigate UI, recordings need to be enabled.
To enable recording video, add the `record` role to a stream and enable it in the config. If record is disabled in the config, it won't be possible to enable it in the UI.
```yaml
```yaml {16-17}
mqtt: ...
detectors: ...
@@ -42,3 +42,7 @@ This is a fork (with fixed errors and new features) of [original Double Take](ht
## [Scrypted - Frigate bridge plugin](https://github.com/apocaliss92/scrypted-frigate-bridge)
[Scrypted - Frigate bridge](https://github.com/apocaliss92/scrypted-frigate-bridge) is an plugin that allows to ingest Frigate detections, motion, videoclips on Scrypted as well as provide templates to export rebroadcast configurations on Frigate.
## [Strix](https://github.com/eduard256/Strix)
[Strix](https://github.com/eduard256/Strix) auto-discovers working stream URLs for IP cameras and generates ready-to-use Frigate configs. It tests thousands of URL patterns against your camera and supports cameras without RTSP or ONVIF. 67K+ camera models from 3.6K+ brands.
+11
View File
@@ -83,6 +83,17 @@ const config: Config = {
},
},
prism: {
magicComments:[
{
className: 'theme-code-block-highlighted-line',
line: 'highlight-next-line',
block: {start: 'highlight-start', end: 'highlight-end'},
},
{
className: 'code-block-error-line',
line: 'highlight-error-line',
},
],
additionalLanguages: ["bash", "json"],
},
languageTabs: [
+8
View File
@@ -234,3 +234,11 @@
content: "schema";
color: var(--ifm-color-secondary-contrast-foreground);
}
.code-block-error-line {
background-color: #ff000020;
display: block;
margin: 0 calc(-1 * var(--ifm-pre-padding));
padding: 0 var(--ifm-pre-padding);
border-left: 3px solid #ff000080;
}
+17 -3
View File
@@ -218,7 +218,7 @@ def config_raw_paths(request: Request):
return JSONResponse(content=raw_paths)
@router.get("/config/raw", dependencies=[Depends(allow_any_authenticated())])
@router.get("/config/raw", dependencies=[Depends(require_role(["admin"]))])
def config_raw():
config_file = find_config_file()
@@ -732,7 +732,12 @@ def get_recognized_license_plates(
@router.get("/timeline", dependencies=[Depends(allow_any_authenticated())])
def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None):
def timeline(
camera: str = "all",
limit: int = 100,
source_id: Optional[str] = None,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
clauses = []
selected_columns = [
@@ -754,6 +759,9 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
else:
clauses.append((Timeline.source_id.in_(source_ids)))
# Enforce per-camera access control
clauses.append((Timeline.camera << allowed_cameras))
if len(clauses) == 0:
clauses.append((True))
@@ -769,7 +777,10 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
@router.get("/timeline/hourly", dependencies=[Depends(allow_any_authenticated())])
def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
def hourly_timeline(
params: AppTimelineHourlyQueryParameters = Depends(),
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
"""Get hourly summary for timeline."""
cameras = params.cameras
labels = params.labels
@@ -787,6 +798,9 @@ def hourly_timeline(params: AppTimelineHourlyQueryParameters = Depends()):
camera_list = cameras.split(",")
clauses.append((Timeline.camera << camera_list))
# Enforce per-camera access control
clauses.append((Timeline.camera << allowed_cameras))
if labels != "all":
label_list = labels.split(",")
clauses.append((Timeline.data["label"] << label_list))
+3 -1
View File
@@ -67,7 +67,6 @@ def require_admin_by_default():
"/stats",
"/stats/history",
"/config",
"/config/raw",
"/vainfo",
"/nvinfo",
"/labels",
@@ -837,6 +836,7 @@ def create_user(
User.notification_tokens: [],
}
).execute()
request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"username": body.username})
@@ -854,6 +854,7 @@ def delete_user(request: Request, username: str):
)
User.delete_by_id(username)
request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"success": True})
@@ -973,6 +974,7 @@ async def update_role(
)
User.set_by_id(username, {User.role: body.role})
request.app.config_publisher.publisher.publish("config/auth", None)
return JSONResponse(content={"success": True})
+87 -39
View File
@@ -50,10 +50,12 @@ from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.file import get_event_thumbnail_bytes
from frigate.util.image import get_image_from_recording
from frigate.util.media import get_keyframe_before
from frigate.util.time import get_dst_transitions
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.media])
@@ -900,6 +902,33 @@ async def vod_ts(
if recording.end_time > end_ts:
duration -= int((recording.end_time - end_ts) * 1000)
# nginx-vod-module pushes clipFrom forward to the next keyframe,
# which can leave too few frames and produce an empty/unplayable
# segment. Snap clipFrom back to the preceding keyframe so the
# segment always starts with a decodable frame.
if "clipFrom" in clip:
keyframe_ms = get_keyframe_before(recording.path, clip["clipFrom"])
if keyframe_ms is not None:
gained = clip["clipFrom"] - keyframe_ms
clip["clipFrom"] = keyframe_ms
duration += gained
logger.debug(
"VOD: snapped clipFrom to keyframe at %sms for %s, duration now %sms",
keyframe_ms,
recording.path,
duration,
)
else:
# could not read keyframes, remove clipFrom to use full recording
logger.debug(
"VOD: no keyframe info for %s, removing clipFrom to use full recording",
recording.path,
)
del clip["clipFrom"]
duration = int(recording.duration * 1000)
if recording.end_time > end_ts:
duration -= int((recording.end_time - end_ts) * 1000)
if duration < min_duration_ms:
# skip if the clip has no valid duration (too short to contain frames)
logger.debug(
@@ -1113,7 +1142,6 @@ async def event_snapshot(
@router.get(
"/events/{event_id}/thumbnail.{extension}",
dependencies=[Depends(require_camera_access)],
)
async def event_thumbnail(
request: Request,
@@ -1157,11 +1185,12 @@ async def event_thumbnail(
status_code=404,
)
img_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
img = cv2.imdecode(img_as_np, flags=1)
# android notifications prefer a 2:1 ratio
if format == "android":
img_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
img = cv2.imdecode(img_as_np, flags=1)
thumbnail = cv2.copyMakeBorder(
img = cv2.copyMakeBorder(
img,
0,
0,
@@ -1171,14 +1200,14 @@ async def event_thumbnail(
(0, 0, 0),
)
quality_params = None
if extension in (Extension.jpg, Extension.jpeg):
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), 70]
elif extension == Extension.webp:
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), 60]
quality_params = None
if extension in (Extension.jpg, Extension.jpeg):
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), 70]
elif extension == Extension.webp:
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), 60]
_, img = cv2.imencode(f".{extension.value}", thumbnail, quality_params)
thumbnail_bytes = img.tobytes()
_, encoded = cv2.imencode(f".{extension.value}", img, quality_params)
thumbnail_bytes = encoded.tobytes()
return Response(
thumbnail_bytes,
@@ -1314,12 +1343,12 @@ def grid_snapshot(
@router.get(
"/events/{event_id}/snapshot-clean.webp",
dependencies=[Depends(require_camera_access)],
)
def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
async def event_snapshot_clean(request: Request, event_id: str, download: bool = False):
webp_bytes = None
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
snapshot_config = request.app.frigate_config.cameras[event.camera].snapshots
if not (snapshot_config.enabled and event.has_snapshot):
return JSONResponse(
@@ -1440,7 +1469,7 @@ def event_snapshot_clean(request: Request, event_id: str, download: bool = False
@router.get(
"/events/{event_id}/clip.mp4", dependencies=[Depends(require_camera_access)]
"/events/{event_id}/clip.mp4",
)
async def event_clip(
request: Request,
@@ -1454,6 +1483,8 @@ async def event_clip(
content={"success": False, "message": "Event not found"}, status_code=404
)
await require_camera_access(event.camera, request=request)
if not event.has_clip:
return JSONResponse(
content={"success": False, "message": "Clip not available"}, status_code=404
@@ -1470,9 +1501,9 @@ async def event_clip(
@router.get(
"/events/{event_id}/preview.gif", dependencies=[Depends(require_camera_access)]
"/events/{event_id}/preview.gif",
)
def event_preview(request: Request, event_id: str):
async def event_preview(request: Request, event_id: str):
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
@@ -1480,6 +1511,8 @@ def event_preview(request: Request, event_id: str):
content={"success": False, "message": "Event not found"}, status_code=404
)
await require_camera_access(event.camera, request=request)
start_ts = event.start_time
end_ts = start_ts + (
min(event.end_time - event.start_time, 20) if event.end_time else 20
@@ -1502,25 +1535,25 @@ def preview_gif(
):
if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0):
# has preview mp4
preview: Previews = (
Previews.select(
Previews.camera,
Previews.path,
Previews.duration,
Previews.start_time,
Previews.end_time,
try:
preview: Previews = (
Previews.select(
Previews.camera,
Previews.path,
Previews.duration,
Previews.start_time,
Previews.end_time,
)
.where(
Previews.start_time.between(start_ts, end_ts)
| Previews.end_time.between(start_ts, end_ts)
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
)
.where(Previews.camera == camera_name)
.limit(1)
.get()
)
.where(
Previews.start_time.between(start_ts, end_ts)
| Previews.end_time.between(start_ts, end_ts)
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
)
.where(Previews.camera == camera_name)
.limit(1)
.get()
)
if not preview:
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Preview not found"},
status_code=404,
@@ -1824,8 +1857,8 @@ def preview_mp4(
)
@router.get("/review/{event_id}/preview", dependencies=[Depends(require_camera_access)])
def review_preview(
@router.get("/review/{event_id}/preview")
async def review_preview(
request: Request,
event_id: str,
format: str = Query(default="gif", enum=["gif", "mp4"]),
@@ -1838,6 +1871,8 @@ def review_preview(
status_code=404,
)
await require_camera_access(review.camera, request=request)
padding = 8
start_ts = review.start_time - padding
end_ts = (
@@ -1851,12 +1886,14 @@ def review_preview(
@router.get(
"/preview/{file_name}/thumbnail.jpg", dependencies=[Depends(require_camera_access)]
"/preview/{file_name}/thumbnail.jpg",
dependencies=[Depends(allow_any_authenticated())],
)
@router.get(
"/preview/{file_name}/thumbnail.webp", dependencies=[Depends(require_camera_access)]
"/preview/{file_name}/thumbnail.webp",
dependencies=[Depends(allow_any_authenticated())],
)
def preview_thumbnail(file_name: str):
async def preview_thumbnail(request: Request, file_name: str):
"""Get a thumbnail from the cached preview frames."""
if len(file_name) > 1000:
return JSONResponse(
@@ -1866,6 +1903,17 @@ def preview_thumbnail(file_name: str):
status_code=403,
)
# Extract camera name from preview filename (format: preview_{camera}-{timestamp}.ext)
if not file_name.startswith("preview_"):
return JSONResponse(
content={"success": False, "message": "Invalid preview filename"},
status_code=400,
)
# Use rsplit to handle camera names containing dashes (e.g. front-door)
name_part = file_name[len("preview_") :].rsplit(".", 1)[0] # strip extension
camera_name = name_part.rsplit("-", 1)[0] # split off timestamp
await require_camera_access(camera_name, request=request)
safe_file_name_current = sanitize_filename(file_name)
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
+59 -11
View File
@@ -17,6 +17,7 @@ from titlecase import titlecase
from frigate.comms.base_communicator import Communicator
from frigate.comms.config_updater import ConfigSubscriber
from frigate.config import FrigateConfig
from frigate.config.auth import AuthConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateSubscriber,
@@ -58,6 +59,7 @@ class WebPushClient(Communicator):
for c in self.config.cameras.values()
}
self.last_notification_time: float = 0
self.user_cameras: dict[str, set[str]] = {}
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
self.notification_thread = threading.Thread(
target=self._process_notifications, daemon=True
@@ -78,13 +80,12 @@ class WebPushClient(Communicator):
for sub in user["notification_tokens"]:
self.web_pushers[user["username"]].append(WebPusher(sub))
# notification config updater
self.global_config_subscriber = ConfigSubscriber(
"config/notifications", exact=True
)
# notification and auth config updater
self.global_config_subscriber = ConfigSubscriber("config/")
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config, self.config.cameras, [CameraConfigUpdateEnum.notifications]
)
self._refresh_user_cameras()
def subscribe(self, receiver: Callable) -> None:
"""Wrapper for allowing dispatcher to subscribe."""
@@ -164,13 +165,19 @@ class WebPushClient(Communicator):
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Wrapper for publishing when client is in valid state."""
# check for updated notification config
_, updated_notification_config = (
self.global_config_subscriber.check_for_update()
)
if updated_notification_config:
self.config.notifications = updated_notification_config
# check for updated global config (notifications, auth)
while True:
config_topic, config_payload = (
self.global_config_subscriber.check_for_update()
)
if config_topic is None:
break
if config_topic == "config/notifications" and config_payload:
self.config.notifications = config_payload
elif config_topic == "config/auth":
if isinstance(config_payload, AuthConfig):
self.config.auth = config_payload
self._refresh_user_cameras()
updates = self.config_subscriber.check_for_updates()
@@ -291,6 +298,31 @@ class WebPushClient(Communicator):
except Exception as e:
logger.error(f"Error processing notification: {str(e)}")
def _refresh_user_cameras(self) -> None:
"""Rebuild the user-to-cameras access cache from the database."""
all_camera_names = set(self.config.cameras.keys())
roles_dict = self.config.auth.roles
updated: dict[str, set[str]] = {}
for user in User.select(User.username, User.role).dicts().iterator():
allowed = User.get_allowed_cameras(
user["role"], roles_dict, all_camera_names
)
updated[user["username"]] = set(allowed)
logger.debug(
"User %s has access to cameras: %s",
user["username"],
", ".join(allowed),
)
self.user_cameras = updated
def _user_has_camera_access(self, username: str, camera: str) -> bool:
"""Check if a user has access to a specific camera based on cached roles."""
allowed = self.user_cameras.get(username)
if allowed is None:
logger.debug(f"No camera access information found for user {username}")
return False
return camera in allowed
def _within_cooldown(self, camera: str) -> bool:
now = datetime.datetime.now().timestamp()
if now - self.last_notification_time < self.config.notifications.cooldown:
@@ -418,6 +450,14 @@ class WebPushClient(Communicator):
logger.debug(f"Sending push notification for {camera}, review ID {reviewId}")
for user in self.web_pushers:
if not self._user_has_camera_access(user, camera):
logger.debug(
"Skipping notification for user %s - no access to camera %s",
user,
camera,
)
continue
self.send_push_notification(
user=user,
payload=payload,
@@ -465,6 +505,14 @@ class WebPushClient(Communicator):
)
for user in self.web_pushers:
if not self._user_has_camera_access(user, camera):
logger.debug(
"Skipping notification for user %s - no access to camera %s",
user,
camera,
)
continue
self.send_push_notification(
user=user,
payload=payload,
+1 -1
View File
@@ -72,7 +72,7 @@ class PtzAutotrackConfig(FrigateBaseModel):
class OnvifConfig(FrigateBaseModel):
host: str = Field(default="", title="Onvif Host")
host: EnvString = Field(default="", title="Onvif Host")
port: int = Field(default=8000, title="Onvif Port")
user: Optional[EnvString] = Field(default=None, title="Onvif Username")
password: Optional[EnvString] = Field(default=None, title="Onvif Password")
+4 -2
View File
@@ -24,8 +24,10 @@ EnvString = Annotated[str, AfterValidator(validate_env_string)]
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
if isinstance(info.context, dict) and info.context.get("install", False):
for k, v in v.items():
os.environ[k] = v
for k, val in v.items():
os.environ[k] = val
if k.startswith("FRIGATE_"):
FRIGATE_ENV_VARS[k] = val
return v
+1 -1
View File
@@ -13,7 +13,7 @@ __all__ = ["MqttConfig"]
class MqttConfig(FrigateBaseModel):
enabled: bool = Field(default=True, title="Enable MQTT Communication.")
host: str = Field(default="", title="MQTT Host")
host: EnvString = Field(default="", title="MQTT Host")
port: int = Field(default=1883, title="MQTT Port")
topic_prefix: str = Field(default="frigate", title="MQTT Topic Prefix")
client_id: str = Field(default="frigate", title="MQTT Client ID")
@@ -463,6 +463,13 @@ class ReviewDescriptionProcessor(PostProcessorApi):
thumbs = []
for idx, thumb_path in enumerate(frame_paths):
thumb_data = cv2.imread(thumb_path)
if thumb_data is None:
logger.warning(
"Could not read preview frame at %s, skipping", thumb_path
)
continue
ret, jpg = cv2.imencode(
".jpg", thumb_data, [int(cv2.IMWRITE_JPEG_QUALITY), 100]
)
+11
View File
@@ -529,6 +529,17 @@ class RKNNModelRunner(BaseModelRunner):
# Transpose from NCHW to NHWC
pixel_data = np.transpose(pixel_data, (0, 2, 3, 1))
rknn_inputs.append(pixel_data)
elif name == "data":
# ArcFace: undo Python normalisation to uint8 [0,255]
# RKNN runtime applies mean=127.5/std=127.5 internally before first layer
face_data = inputs[name]
if len(face_data.shape) == 4 and face_data.shape[1] == 3:
# Transpose from NCHW to NHWC
face_data = np.transpose(face_data, (0, 2, 3, 1))
face_data = (
((face_data + 1.0) * 127.5).clip(0, 255).astype(np.uint8)
)
rknn_inputs.append(face_data)
else:
rknn_inputs.append(inputs[name])
+3 -3
View File
@@ -321,6 +321,9 @@ class AudioEventMaintainer(threading.Thread):
self.start_or_restart_ffmpeg()
while not self.stop_event.is_set():
# check if there is an updated config
self.config_subscriber.check_for_updates()
enabled = self.camera_config.enabled
if enabled != self.was_enabled:
if enabled:
@@ -347,9 +350,6 @@ class AudioEventMaintainer(threading.Thread):
time.sleep(0.1)
continue
# check if there is an updated config
self.config_subscriber.check_for_updates()
self.read_audio()
if self.audio_listener:
+4
View File
@@ -324,6 +324,10 @@ class EventCleanup(threading.Thread):
return events_to_update
def run(self) -> None:
if self.config.safe_mode:
logger.info("Safe mode enabled, skipping event cleanup")
return
# only expire events every 5 minutes
while not self.stop_event.wait(300):
events_with_expired_clips = self.expire_clips()
+4
View File
@@ -350,6 +350,10 @@ class RecordingCleanup(threading.Thread):
logger.debug("End expire recordings.")
def run(self) -> None:
if self.config.safe_mode:
logger.info("Safe mode enabled, skipping recording cleanup")
return
# on startup sync recordings with disk if enabled
if self.config.record.sync_recordings:
sync_recordings(limited=False)
+4
View File
@@ -272,6 +272,10 @@ class StorageMaintainer(threading.Thread):
def run(self):
"""Check every 5 minutes if storage needs to be cleaned up."""
if self.config.safe_mode:
logger.info("Safe mode enabled, skipping storage maintenance")
return
self.calculate_camera_bandwidth()
while not self.stop_event.wait(300):
if not self.camera_storage_stats or True in [
+105
View File
@@ -0,0 +1,105 @@
"""Tests for environment variable handling."""
import os
import unittest
from frigate.config.env import (
FRIGATE_ENV_VARS,
validate_env_string,
validate_env_vars,
)
class TestEnvString(unittest.TestCase):
def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS)
def tearDown(self):
FRIGATE_ENV_VARS.clear()
FRIGATE_ENV_VARS.update(self._original_env_vars)
def test_substitution(self):
"""EnvString substitutes FRIGATE_ env vars."""
FRIGATE_ENV_VARS["FRIGATE_TEST_HOST"] = "192.168.1.100"
result = validate_env_string("{FRIGATE_TEST_HOST}")
self.assertEqual(result, "192.168.1.100")
def test_substitution_in_url(self):
"""EnvString substitutes vars embedded in a URL."""
FRIGATE_ENV_VARS["FRIGATE_CAM_USER"] = "admin"
FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"] = "secret"
result = validate_env_string(
"rtsp://{FRIGATE_CAM_USER}:{FRIGATE_CAM_PASS}@10.0.0.1/stream"
)
self.assertEqual(result, "rtsp://admin:secret@10.0.0.1/stream")
def test_no_placeholder(self):
"""Plain strings pass through unchanged."""
result = validate_env_string("192.168.1.1")
self.assertEqual(result, "192.168.1.1")
def test_unknown_var_raises(self):
"""Referencing an unknown var raises KeyError."""
with self.assertRaises(KeyError):
validate_env_string("{FRIGATE_NONEXISTENT_VAR}")
class TestEnvVars(unittest.TestCase):
def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS)
self._original_environ = os.environ.copy()
def tearDown(self):
FRIGATE_ENV_VARS.clear()
FRIGATE_ENV_VARS.update(self._original_env_vars)
# Clean up any env vars we set
for key in list(os.environ.keys()):
if key not in self._original_environ:
del os.environ[key]
def _make_context(self, install: bool):
"""Create a mock ValidationInfo with the given install flag."""
class MockContext:
def __init__(self, ctx):
self.context = ctx
mock = MockContext({"install": install})
return mock
def test_install_sets_os_environ(self):
"""validate_env_vars with install=True sets os.environ."""
ctx = self._make_context(install=True)
validate_env_vars({"MY_CUSTOM_VAR": "value123"}, ctx)
self.assertEqual(os.environ.get("MY_CUSTOM_VAR"), "value123")
def test_install_updates_frigate_env_vars(self):
"""validate_env_vars with install=True updates FRIGATE_ENV_VARS for FRIGATE_ keys."""
ctx = self._make_context(install=True)
validate_env_vars({"FRIGATE_MQTT_PASS": "secret"}, ctx)
self.assertEqual(FRIGATE_ENV_VARS["FRIGATE_MQTT_PASS"], "secret")
def test_install_skips_non_frigate_in_env_vars_dict(self):
"""Non-FRIGATE_ keys are set in os.environ but not in FRIGATE_ENV_VARS."""
ctx = self._make_context(install=True)
validate_env_vars({"OTHER_VAR": "value"}, ctx)
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
self.assertNotIn("OTHER_VAR", FRIGATE_ENV_VARS)
def test_no_install_does_not_set(self):
"""validate_env_vars without install=True does not modify state."""
ctx = self._make_context(install=False)
validate_env_vars({"FRIGATE_SKIP": "nope"}, ctx)
self.assertNotIn("FRIGATE_SKIP", FRIGATE_ENV_VARS)
self.assertNotIn("FRIGATE_SKIP", os.environ)
def test_env_vars_available_for_env_string(self):
"""Vars set via validate_env_vars are usable in validate_env_string."""
ctx = self._make_context(install=True)
validate_env_vars({"FRIGATE_BROKER": "mqtt.local"}, ctx)
result = validate_env_string("{FRIGATE_BROKER}")
self.assertEqual(result, "mqtt.local")
if __name__ == "__main__":
unittest.main()
+11
View File
@@ -55,6 +55,14 @@ DYNAMIC_OBJECT_THRESHOLDS = StationaryThresholds(
motion_classifier_enabled=True,
)
# Thresholds for objects that are not expected to be stationary
NON_STATIONARY_OBJECT_THRESHOLDS = StationaryThresholds(
objects=["license_plate"],
known_active_iou=0.9,
stationary_check_iou=0.9,
max_stationary_history=4,
)
def get_stationary_threshold(label: str) -> StationaryThresholds:
"""Get the stationary thresholds for a given object label."""
@@ -65,6 +73,9 @@ def get_stationary_threshold(label: str) -> StationaryThresholds:
if label in DYNAMIC_OBJECT_THRESHOLDS.objects:
return DYNAMIC_OBJECT_THRESHOLDS
if label in NON_STATIONARY_OBJECT_THRESHOLDS.objects:
return NON_STATIONARY_OBJECT_THRESHOLDS
return StationaryThresholds()
+61
View File
@@ -0,0 +1,61 @@
"""Utilities for media file inspection."""
import subprocess as sp
from frigate.const import DEFAULT_FFMPEG_VERSION
FFPROBE_PATH = (
f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe"
if DEFAULT_FFMPEG_VERSION
else "ffprobe"
)
def get_keyframe_before(path: str, offset_ms: int) -> int | None:
"""Get the timestamp (ms) of the last keyframe at or before offset_ms.
Uses ffprobe packet index to read keyframe positions from the mp4 file.
Returns None if ffprobe fails or no keyframe is found before the offset.
"""
try:
result = sp.run(
[
FFPROBE_PATH,
"-select_streams",
"v:0",
"-show_entries",
"packet=pts_time,flags",
"-of",
"csv=p=0",
"-loglevel",
"error",
path,
],
capture_output=True,
timeout=5,
)
except (sp.TimeoutExpired, FileNotFoundError):
return None
if result.returncode != 0:
return None
offset_s = offset_ms / 1000.0
best_ms = None
for line in result.stdout.decode().strip().splitlines():
parts = line.strip().split(",")
if len(parts) != 2:
continue
ts_str, flags = parts
if "K" not in flags:
continue
try:
ts = float(ts_str)
except ValueError:
continue
if ts <= offset_s:
best_ms = int(ts * 1000)
else:
break
return best_ms
+2 -2
View File
@@ -436,7 +436,7 @@ class CameraWatchdog(threading.Thread):
for role in p["roles"]:
self.requestor.send_data(
f"{self.config.name}/status/{role}", "offline"
f"{self.config.name}/status/{role.value}", "offline"
)
continue
@@ -451,7 +451,7 @@ class CameraWatchdog(threading.Thread):
for role in p["roles"]:
self.requestor.send_data(
f"{self.config.name}/status/{role}", "offline"
f"{self.config.name}/status/{role.value}", "offline"
)
p["logpipe"].dump()
@@ -213,6 +213,7 @@ export function AnimatedEventCard({
playsInline
muted
disableRemotePlayback
disablePictureInPicture
loop
onTimeUpdate={() => {
if (!isLoaded) {
+15 -13
View File
@@ -126,19 +126,21 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
<DropdownMenuSeparator className={isDesktop ? "my-2" : "my-2"} />
{profile?.username && profile.username !== "anonymous" && (
<MenuItem
className={cn(
"flex w-full items-center gap-2",
isDesktop ? "cursor-pointer" : "p-2 text-sm",
)}
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
</MenuItem>
)}
{config?.auth?.enabled !== false &&
profile?.username &&
profile.username !== "anonymous" && (
<MenuItem
className={cn(
"flex w-full items-center gap-2",
isDesktop ? "cursor-pointer" : "p-2 text-sm",
)}
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
</MenuItem>
)}
<MenuItem
className={cn(
+18 -14
View File
@@ -225,20 +225,24 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<DropdownMenuSeparator
className={isDesktop ? "mt-3" : "mt-1"}
/>
{profile?.username && profile.username !== "anonymous" && (
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>{t("menu.user.setPassword", { ns: "common" })}</span>
</MenuItem>
)}
{config?.auth?.enabled !== false &&
profile?.username &&
profile.username !== "anonymous" && (
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
aria-label={t("menu.user.setPassword", { ns: "common" })}
onClick={() => setPasswordDialogOpen(true)}
>
<LuSquarePen className="mr-2 size-4" />
<span>
{t("menu.user.setPassword", { ns: "common" })}
</span>
</MenuItem>
)}
<MenuItem
className={
isDesktop
@@ -125,17 +125,23 @@ export default function ClassificationSelectionDialog({
isMobile && "gap-2 pb-4",
)}
>
{classes.sort().map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
{classes
.sort((a, b) => {
if (a === "none") return 1;
if (b === "none") return -1;
return a.localeCompare(b);
})
.map((category) => (
<SelectorItem
key={category}
className="flex cursor-pointer gap-2 smart-capitalize"
onClick={() => onCategorizeImage(category)}
>
{category === "none"
? t("details.none")
: category.replaceAll("_", " ")}
</SelectorItem>
))}
<Separator />
<SelectorItem
className="flex cursor-pointer gap-2 smart-capitalize"
@@ -495,6 +495,15 @@ export default function SearchDetailDialog({
}
}, [search]);
useEffect(() => {
if (!isDesktop || !onPrevious || !onNext) {
setShowNavigationButtons(false);
return;
}
setShowNavigationButtons(isOpen);
}, [isOpen, onNext, onPrevious]);
// show/hide annotation settings is handled inside TabsWithActions
const searchTabs = useMemo(() => {
@@ -40,6 +40,7 @@ import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator
import ObjectTrackOverlay from "../ObjectTrackOverlay";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { VideoResolutionType } from "@/types/live";
import { VodManifest } from "@/types/playback";
type TrackingDetailsProps = {
className?: string;
@@ -117,19 +118,64 @@ export function TrackingDetails({
},
);
// Fetch the VOD manifest JSON to get the actual clipFrom after keyframe
// snapping. The backend may snap clipFrom backwards to a keyframe, making
// the video start earlier than the requested time.
const vodManifestUrl = useMemo(() => {
if (!event.camera) return null;
const startTime =
event.start_time + annotationOffset / 1000 - REVIEW_PADDING;
const endTime =
(event.end_time ?? Date.now() / 1000) +
annotationOffset / 1000 +
REVIEW_PADDING;
return `vod/clip/${event.camera}/start/${startTime}/end/${endTime}`;
}, [event, annotationOffset]);
const { data: vodManifest } = useSWR<VodManifest>(vodManifestUrl, null, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30000,
});
// Derive the actual video start time from the VOD manifest's first clip.
// Without this correction the timeline-to-player-time mapping is off by
// the keyframe preroll amount.
const actualVideoStart = useMemo(() => {
const videoStartTime = eventStartRecord - REVIEW_PADDING;
if (!vodManifest?.sequences?.[0]?.clips?.[0] || !recordings?.length) {
return videoStartTime;
}
const firstClip = vodManifest.sequences[0].clips[0];
// Guard: clipFrom is only expected when the first recording starts before
// the requested start. If this doesn't hold, fall back.
if (recordings[0].start_time >= videoStartTime) {
return recordings[0].start_time;
}
if (firstClip.clipFrom !== undefined) {
// clipFrom is in milliseconds from the start of the first recording
return recordings[0].start_time + firstClip.clipFrom / 1000;
}
// clipFrom absent means the full recording is included (keyframe probe failed)
return recordings[0].start_time;
}, [vodManifest, recordings, eventStartRecord]);
// Convert a timeline timestamp to actual video player time, accounting for
// motion-only recording gaps. Uses the same algorithm as DynamicVideoController.
const timestampToVideoTime = useCallback(
(timestamp: number): number => {
if (!recordings || recordings.length === 0) {
// Fallback to simple calculation if no recordings data
return timestamp - (eventStartRecord - REVIEW_PADDING);
return timestamp - actualVideoStart;
}
const videoStartTime = eventStartRecord - REVIEW_PADDING;
// If timestamp is before video start, return 0
if (timestamp < videoStartTime) return 0;
// If timestamp is before actual video start, return 0
if (timestamp < actualVideoStart) return 0;
// Check if timestamp is before the first recording or after the last
if (
@@ -143,10 +189,10 @@ export function TrackingDetails({
// Calculate the inpoint offset - the HLS video may start partway through the first segment
let inpointOffset = 0;
if (
videoStartTime > recordings[0].start_time &&
videoStartTime < recordings[0].end_time
actualVideoStart > recordings[0].start_time &&
actualVideoStart < recordings[0].end_time
) {
inpointOffset = videoStartTime - recordings[0].start_time;
inpointOffset = actualVideoStart - recordings[0].start_time;
}
let seekSeconds = 0;
@@ -164,7 +210,7 @@ export function TrackingDetails({
if (segment === recordings[0]) {
// For the first segment, account for the inpoint offset
seekSeconds +=
timestamp - Math.max(segment.start_time, videoStartTime);
timestamp - Math.max(segment.start_time, actualVideoStart);
} else {
seekSeconds += timestamp - segment.start_time;
}
@@ -174,7 +220,7 @@ export function TrackingDetails({
return seekSeconds;
},
[recordings, eventStartRecord],
[recordings, actualVideoStart],
);
// Convert video player time back to timeline timestamp, accounting for
@@ -183,19 +229,16 @@ export function TrackingDetails({
(playerTime: number): number => {
if (!recordings || recordings.length === 0) {
// Fallback to simple calculation if no recordings data
const videoStartTime = eventStartRecord - REVIEW_PADDING;
return playerTime + videoStartTime;
return playerTime + actualVideoStart;
}
const videoStartTime = eventStartRecord - REVIEW_PADDING;
// Calculate the inpoint offset - the video may start partway through the first segment
let inpointOffset = 0;
if (
videoStartTime > recordings[0].start_time &&
videoStartTime < recordings[0].end_time
actualVideoStart > recordings[0].start_time &&
actualVideoStart < recordings[0].end_time
) {
inpointOffset = videoStartTime - recordings[0].start_time;
inpointOffset = actualVideoStart - recordings[0].start_time;
}
let timestamp = 0;
@@ -212,7 +255,7 @@ export function TrackingDetails({
if (segment === recordings[0]) {
// For the first segment, add the inpoint offset
timestamp =
Math.max(segment.start_time, videoStartTime) +
Math.max(segment.start_time, actualVideoStart) +
(playerTime - totalTime);
} else {
timestamp = segment.start_time + (playerTime - totalTime);
@@ -225,7 +268,7 @@ export function TrackingDetails({
return timestamp;
},
[recordings, eventStartRecord],
[recordings, actualVideoStart],
);
eventSequence?.map((event) => {
+1 -1
View File
@@ -352,7 +352,7 @@ export default function HlsVideoPlayer({
>
{isDetailMode &&
camera &&
currentTime &&
currentTime != null &&
loadedMetadata &&
videoDimensions.width > 0 &&
videoDimensions.height > 0 && (
@@ -20,7 +20,10 @@ import type {
CameraConfigData,
ConfigSetBody,
} from "@/types/cameraWizard";
import { processCameraName } from "@/utils/cameraUtil";
import {
processCameraName,
calculateDetectDimensions,
} from "@/utils/cameraUtil";
import { cn } from "@/lib/utils";
type WizardState = {
@@ -203,6 +206,25 @@ export default function CameraWizardDialog({
},
};
// Calculate detect dimensions from the detect stream's probed resolution
const detectStream = wizardData.streams.find((stream) =>
stream.roles.includes("detect"),
);
if (detectStream?.testResult?.resolution) {
const [streamWidth, streamHeight] = detectStream.testResult.resolution
.split("x")
.map(Number);
if (streamWidth > 0 && streamHeight > 0) {
const detectDimensions = calculateDetectDimensions(
streamWidth,
streamHeight,
);
if (detectDimensions) {
configData.cameras[finalCameraName].detect = detectDimensions;
}
}
}
// Add live.streams configuration for go2rtc streams
if (wizardData.streams && wizardData.streams.length > 0) {
configData.cameras[finalCameraName].live = {
+3 -3
View File
@@ -598,18 +598,18 @@ function LibrarySelector({
{Object.values(faces).map((face) => (
<DropdownMenuItem
key={face}
className="group flex items-center justify-between"
className="group flex items-center justify-between p-0"
>
<div
className="flex-grow cursor-pointer"
onClick={() => setPageToggle(face)}
>
{face}
<span className="ml-2 text-muted-foreground">
<span className="ml-2 px-2 py-1.5 text-muted-foreground">
({faceData?.[face].length})
</span>
</div>
<div className="flex gap-0.5">
<div className="flex gap-0.5 px-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
+4
View File
@@ -162,6 +162,10 @@ export type CameraConfigData = {
input_args?: string;
}[];
};
detect?: {
width: number;
height: number;
};
live?: {
streams: Record<string, string>;
};
+1
View File
@@ -346,6 +346,7 @@ export interface FrigateConfig {
};
auth: {
enabled: boolean;
roles: {
[roleName: string]: string[];
};
+4
View File
@@ -11,3 +11,7 @@ export type PreviewPlayback = {
preview: Preview | undefined;
timeRange: TimeRange;
};
export type VodManifest = {
sequences: { clips: { clipFrom?: number }[] }[];
};
+45
View File
@@ -115,6 +115,51 @@ export type CameraAudioFeatures = {
* @param requireSecureContext - If true, two-way audio requires secure context (default: true)
* @returns CameraAudioFeatures object with detected capabilities
*/
/**
* Calculates optimal detect dimensions from stream resolution.
*
* Scales dimensions to an efficient size for object detection while
* preserving the stream's aspect ratio. Does not upscale.
*
* @param streamWidth - Native stream width in pixels
* @param streamHeight - Native stream height in pixels
* @returns Detect dimensions with even values, or null if inputs are invalid
*/
// Target size for the smaller dimension (width or height) for detect streams
export const DETECT_TARGET_PX = 720;
export function calculateDetectDimensions(
streamWidth: number,
streamHeight: number,
): { width: number; height: number } | null {
if (
!Number.isFinite(streamWidth) ||
!Number.isFinite(streamHeight) ||
streamWidth <= 0 ||
streamHeight <= 0
) {
return null;
}
const smallerDim = Math.min(streamWidth, streamHeight);
const target = Math.min(DETECT_TARGET_PX, smallerDim);
const scale = target / smallerDim;
let width = Math.round(streamWidth * scale);
let height = Math.round(streamHeight * scale);
// Round down to even numbers (required for video processing)
width = width - (width % 2);
height = height - (height % 2);
if (width < 2 || height < 2) {
return null;
}
return { width, height };
}
export function detectCameraAudioFeatures(
metadata: LiveStreamMetadata | null | undefined,
requireSecureContext: boolean = true,
@@ -700,66 +700,72 @@ function LibrarySelector({
</div>
</>
)}
{Object.keys(dataset).map((id) => (
<DropdownMenuItem
key={id}
className="group flex items-center justify-between"
>
<div
className="flex-grow cursor-pointer capitalize"
onClick={() => setPageToggle(id)}
{Object.keys(dataset)
.sort((a, b) => {
if (a === "none") return 1;
if (b === "none") return -1;
return a.localeCompare(b);
})
.map((id) => (
<DropdownMenuItem
key={id}
className="group flex items-center justify-between p-0"
>
{id === "none" ? t("details.none") : id.replaceAll("_", " ")}
<span className="ml-2 text-muted-foreground">
({dataset?.[id].length})
</span>
</div>
{id != "none" && (
<div className="flex gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setRenameClass(id);
}}
>
<LuPencil className="size-4 text-primary" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.renameCategory")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(id);
}}
>
<LuTrash2 className="size-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.deleteCategory")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<div
className="flex-grow cursor-pointer px-2 py-1.5 capitalize"
onClick={() => setPageToggle(id)}
>
{id === "none" ? t("details.none") : id.replaceAll("_", " ")}
<span className="ml-2 text-muted-foreground">
({dataset?.[id].length})
</span>
</div>
)}
</DropdownMenuItem>
))}
{id != "none" && (
<div className="flex gap-0.5 px-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setRenameClass(id);
}}
>
<LuPencil className="size-4 text-primary" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.renameCategory")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(id);
}}
>
<LuTrash2 className="size-4 text-destructive" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.deleteCategory")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</>