mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-21 19:29:00 +03:00
Compare commits
46
Commits
f316244495
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50a2b6729e | ||
|
|
3d4dd3ac4b | ||
|
|
933a7f1a3f | ||
|
|
4e5e8e3c59 | ||
|
|
b3ce4486b9 | ||
|
|
06e3d0ac5d | ||
|
|
28e3e1ec74 | ||
|
|
fa07109a85 | ||
|
|
910059281f | ||
|
|
ef44c18c07 | ||
|
|
06b059c36a | ||
|
|
26d31300e6 | ||
|
|
0013555528 | ||
|
|
2cfb530dbf | ||
|
|
81b0d94793 | ||
|
|
67837f61d0 | ||
|
|
58c93c2e9e | ||
|
|
6b71feffab | ||
|
|
1c26bc289e | ||
|
|
0371b60c71 | ||
|
|
01392e03ac | ||
|
|
416a9b7692 | ||
|
|
d11c26970d | ||
|
|
e78da2758d | ||
|
|
ae9b307dfc | ||
|
|
01c16a9250 | ||
|
|
d4731c1dea | ||
|
|
65ca90db20 | ||
|
|
3ec2305e6a | ||
|
|
8b035be132 | ||
|
|
be79ad89b6 | ||
|
|
b147b53522 | ||
|
|
d2b2faa2d7 | ||
|
|
614a6b39d4 | ||
|
|
f29ee53fb4 | ||
|
|
544d3c6139 | ||
|
|
104e623923 | ||
|
|
59fc8449ed | ||
|
|
19480867fb | ||
|
|
1188d87588 | ||
|
|
c4a5ac0e77 | ||
|
|
41e290449e | ||
|
|
b6f78bd1f2 | ||
|
|
bde518e861 | ||
|
|
4e71a835cb | ||
|
|
537e723c30 |
@@ -1,7 +1,7 @@
|
||||
default_target: local
|
||||
|
||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||
VERSION = 0.17.1
|
||||
VERSION = 0.17.2
|
||||
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
||||
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
BOARDS= #Initialized empty
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,36 +17,15 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
|
||||
from frigate.util.config import find_config_file
|
||||
from frigate.util.services import (
|
||||
is_go2rtc_arbitrary_exec_allowed,
|
||||
is_restricted_go2rtc_source,
|
||||
)
|
||||
|
||||
sys.path.remove("/opt/frigate")
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
# Check if arbitrary exec sources are allowed (defaults to False for security)
|
||||
allow_arbitrary_exec = None
|
||||
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
|
||||
allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
|
||||
elif (
|
||||
os.path.isdir("/run/secrets")
|
||||
and os.access("/run/secrets", os.R_OK)
|
||||
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
|
||||
):
|
||||
allow_arbitrary_exec = (
|
||||
Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC"))
|
||||
.read_text()
|
||||
.strip()
|
||||
)
|
||||
# check for the add-on options file
|
||||
elif os.path.isfile("/data/options.json"):
|
||||
with open("/data/options.json") as f:
|
||||
raw_options = f.read()
|
||||
options = json.loads(raw_options)
|
||||
allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec")
|
||||
|
||||
ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str(
|
||||
allow_arbitrary_exec
|
||||
).lower() in ("true", "1", "yes")
|
||||
|
||||
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
||||
# read docker secret files as env vars too
|
||||
if os.path.isdir("/run/secrets"):
|
||||
@@ -135,18 +114,13 @@ if LIBAVFORMAT_VERSION_MAJOR < 59:
|
||||
go2rtc_config["ffmpeg"]["rtsp"] = rtsp_args
|
||||
|
||||
|
||||
def is_restricted_source(stream_source: str) -> bool:
|
||||
"""Check if a stream source is restricted (echo, expr, or exec)."""
|
||||
return stream_source.strip().startswith(("echo:", "expr:", "exec:"))
|
||||
|
||||
|
||||
for name in list(go2rtc_config.get("streams", {})):
|
||||
stream = go2rtc_config["streams"][name]
|
||||
|
||||
if isinstance(stream, str):
|
||||
try:
|
||||
formatted_stream = stream.format(**FRIGATE_ENV_VARS)
|
||||
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
|
||||
if is_restricted_go2rtc_source(formatted_stream):
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
||||
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
|
||||
@@ -165,7 +139,7 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
for i, stream_item in enumerate(stream):
|
||||
try:
|
||||
formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
|
||||
if not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream):
|
||||
if is_restricted_go2rtc_source(formatted_stream):
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "
|
||||
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
|
||||
@@ -188,6 +162,20 @@ for name in list(go2rtc_config.get("streams", {})):
|
||||
)
|
||||
del go2rtc_config["streams"][name]
|
||||
|
||||
elif isinstance(stream, dict):
|
||||
# The map form ({"url": ...}) lets go2rtc resolve the source
|
||||
# recursively, so it is effectively a dynamic way to generate the URL
|
||||
# for a stream. That can only be backed by an exec source, so it cannot
|
||||
# be allowed unless arbitrary exec is explicitly enabled. When it is
|
||||
# enabled, leave the map untouched for go2rtc to resolve.
|
||||
if not is_go2rtc_arbitrary_exec_allowed():
|
||||
print(
|
||||
f"[ERROR] Stream '{name}' uses a dynamic source format which is disabled by default for security. "
|
||||
f"Set GO2RTC_ALLOW_ARBITRARY_EXEC=true to enable arbitrary exec sources."
|
||||
)
|
||||
del go2rtc_config["streams"][name]
|
||||
continue
|
||||
|
||||
# add birdseye restream stream if enabled
|
||||
if config.get("birdseye", {}).get("restream", False):
|
||||
birdseye: dict[str, Any] = config.get("birdseye")
|
||||
|
||||
@@ -259,6 +259,7 @@ http {
|
||||
include proxy.conf;
|
||||
|
||||
proxy_cache api_cache;
|
||||
proxy_cache_key "$scheme$proxy_host$request_uri|$role|$groups|$user";
|
||||
proxy_cache_lock on;
|
||||
proxy_cache_use_stale updating;
|
||||
proxy_cache_valid 200 5s;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -118,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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Face recognition identifies known individuals by matching detected faces with pr
|
||||
|
||||
### Face Detection
|
||||
|
||||
When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
|
||||
When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/index.md#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
|
||||
|
||||
When running a default COCO model or another model that does not include `face` as a detectable label, face detection will run via CV2 using a lightweight DNN model that runs on the CPU. In this case, you should _not_ define `face` in your list of objects to track.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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:
|
||||
...
|
||||
@@ -391,6 +391,7 @@ Start with ["Why isn't my license plate being detected and recognized?"](#why-is
|
||||
logger:
|
||||
default: info
|
||||
logs:
|
||||
# highlight-next-line
|
||||
frigate.data_processing.common.license_plate: debug
|
||||
```
|
||||
|
||||
|
||||
@@ -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:
|
||||
...
|
||||
|
||||
@@ -3,6 +3,8 @@ id: masks
|
||||
title: Masks
|
||||
---
|
||||
|
||||
Frigate has two kinds of masks: motion masks and object filter masks. Both are narrow tools for fine-tuning, **not for hiding an area from Frigate**. Masks should be used sparingly; in most cases where users reach for one, a [zone](zones.md) with `required_zones` is the right tool instead. See [Which tool do I need?](#which-tool-do-i-need) and [Common mistakes](#common-mistakes) below if you're new to Frigate's mask behavior.
|
||||
|
||||
## Motion masks
|
||||
|
||||
Motion masks are used to prevent unwanted types of motion from triggering detection. Try watching the Debug feed (Settings --> Debug) with `Motion Boxes` enabled to see what may be regularly detected as motion. For example, you want to mask out your timestamp, the sky, rooftops, etc. Keep in mind that this mask only prevents motion from being detected and does not prevent objects from being detected if object detection was started due to motion in unmasked areas. Motion is also used during object tracking to refine the object detection area in the next frame. _Over-masking will make it more difficult for objects to be tracked._
|
||||
@@ -17,6 +19,15 @@ Object filter masks can be used to filter out stubborn false positives in fixed
|
||||
|
||||

|
||||
|
||||
## Which tool do I need?
|
||||
|
||||
| What you're trying to do | Recommended tool | How it works |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Don't get alerts or recordings for activity in an area (e.g., the sidewalk in front of your house) | A [zone](zones.md) combined with `review.alerts.required_zones` (and/or `review.detections.required_zones`) | Frigate keeps detecting and tracking activity in the area, but a review item is only created once the bottom-center of an object's bounding box enters a required zone. |
|
||||
| Stop a stubborn false positive at a specific fixed spot (e.g., a tree base that keeps being detected as a person) | An **object filter mask** for that object type | Any detection of that object type whose bounding-box bottom-center lands inside the mask is treated as a false positive and discarded. |
|
||||
| Ignore motion in an area that obviously isn't an object of interest (e.g., the camera timestamp, sky, flags, treetops swaying) | A **motion mask** | Motion inside the mask is ignored when deciding whether to run object detection. Objects can still be detected in a motion masked area if motion elsewhere in the frame triggers detection. |
|
||||
| Stop tracking an object type altogether on this camera (e.g., you never care about cats) | Remove the object from the camera's [`objects.track`](objects.md) list | Frigate skips this object type entirely on this camera, regardless of where it appears. |
|
||||
|
||||
## Using the mask creator
|
||||
|
||||
To create a poly mask:
|
||||
@@ -82,3 +93,14 @@ This is what `required_zones` are for. You should define a zone (remember this i
|
||||
> Maybe my specific situation just warrants this. I've just been having a hard time understanding the relevance of this information - it seems to be that it's exactly what would be expected when "masking out" an area of ANY image.
|
||||
|
||||
That may be the case for you. Frigate will definitely work harder tracking people on the sidewalk to make sure it doesn't miss anyone who steps foot on your stoop. The trade off with the way you have it now is slower recognition of objects and potential misses. That may be acceptable based on your needs. Also, if your resolution is low enough on the detect stream, your regions may already be so big that they grab the entire object anyway.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
**"I added a motion mask to ignore my driveway/sidewalk."**
|
||||
A motion mask doesn't hide an area from Frigate. Objects can still be detected and tracked inside a masked area. The mask only stops motion _in that area_ from triggering object detection. If you want activity on the sidewalk to never produce a review item, define a [zone](zones.md) over the area you DO care about (your stoop, your driveway) and add it to `review.alerts.required_zones`. Frigate will still see people on the sidewalk, but it won't create an alert until they cross into the zone.
|
||||
|
||||
**"I added an object filter mask because I don't care about cars in my yard."**
|
||||
Object filter masks are for stubborn false positives at fixed locations, not for filtering whole areas or whole object types. If you only want alerts when a car enters the driveway, use a [zone](zones.md) with `required_zones`. If you don't care about a whole object type on this camera, remove it from [`objects.track`](objects.md).
|
||||
|
||||
**"I masked everything except a thin strip on my stoop."**
|
||||
Heavy masking hurts tracking. Frigate uses motion near a tracked object's previous bounding box to decide where to look in the next frame; with most of the frame masked, an object walking from an unmasked area into a masked one effectively disappears and gets picked up as a "new" object when it reappears. For example: someone walks down your sidewalk, stops under a tree (masked area) to tie their shoe, then continues. Frigate sees that as two separate people and can create two separate review items. Because Frigate needs several consecutive frames above the confidence threshold to commit to a detection, each re-appearance can also delay or miss alerts. Use `required_zones` for "only alert me about this spot" and leave the surrounding area unmasked so tracking stays intact.
|
||||
|
||||
@@ -330,7 +330,7 @@ detectors:
|
||||
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
|
||||
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
|
||||
| [YOLOX](#yolox) | ✅ | ? | |
|
||||
| [D-FINE](#d-fine) | ❌ | ❌ | |
|
||||
| [D-FINE / DEIMv2](#d-fine--deimv2) | ❌ | ❌ | |
|
||||
|
||||
#### SSDLite MobileNet v2
|
||||
|
||||
@@ -464,13 +464,13 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
#### D-FINE
|
||||
#### D-FINE / DEIMv2
|
||||
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
|
||||
|
||||
:::warning
|
||||
|
||||
Currently D-FINE models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model
|
||||
Currently D-FINE / DEIMv2 models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model
|
||||
|
||||
:::
|
||||
|
||||
@@ -499,6 +499,31 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>DEIMv2 Setup & Config</summary>
|
||||
|
||||
After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
ov:
|
||||
type: openvino
|
||||
device: CPU
|
||||
|
||||
model:
|
||||
model_type: dfine
|
||||
width: 640
|
||||
height: 640
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/model_cache/deimv2_hgnetv2_n.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
```
|
||||
|
||||
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
||||
|
||||
</details>
|
||||
|
||||
## Apple Silicon detector
|
||||
|
||||
The NPU in Apple Silicon can't be accessed from within a container, so the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) must first be setup. It is recommended to use the Frigate docker image with `-standard-arm64` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-standard-arm64`.
|
||||
@@ -572,7 +597,7 @@ $ docker run --device=/dev/kfd --device=/dev/dri \
|
||||
|
||||
When using Docker Compose:
|
||||
|
||||
```yaml
|
||||
```yaml {4-6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -603,7 +628,7 @@ $ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \
|
||||
|
||||
When using Docker Compose:
|
||||
|
||||
```yaml
|
||||
```yaml {4-5}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
@@ -648,7 +673,7 @@ The AMD GPU kernel is known problematic especially when converting models to mxr
|
||||
|
||||
See [ONNX supported models](#supported-models) for supported models, there are some caveats:
|
||||
|
||||
- D-FINE models are not supported
|
||||
- D-FINE / DEIMv2 models are not supported
|
||||
- YOLO-NAS models are known to not run well on integrated GPUs
|
||||
|
||||
## ONNX
|
||||
@@ -693,7 +718,7 @@ detectors:
|
||||
| [RF-DETR](#rf-detr) | ✅ | ❌ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [YOLO-NAS](#yolo-nas-1) | ⚠️ | ⚠️ | Not supported by CUDA Graphs |
|
||||
| [YOLOX](#yolox-1) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [D-FINE](#d-fine) | ⚠️ | ❌ | Not supported by CUDA Graphs |
|
||||
| [D-FINE / DEIMv2](#d-fine--deimv2-1) | ⚠️ | ❌ | Not supported by CUDA Graphs |
|
||||
|
||||
There is no default model provided, the following formats are supported:
|
||||
|
||||
@@ -822,9 +847,9 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
#### D-FINE
|
||||
#### D-FINE / DEIMv2
|
||||
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
|
||||
|
||||
<details>
|
||||
<summary>D-FINE Setup & Config</summary>
|
||||
@@ -848,6 +873,28 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>DEIMv2 Setup & Config</summary>
|
||||
|
||||
After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
onnx:
|
||||
type: onnx
|
||||
|
||||
model:
|
||||
model_type: dfine
|
||||
width: 640
|
||||
height: 640
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/model_cache/deimv2_hgnetv2_n.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
||||
|
||||
## CPU Detector (not recommended)
|
||||
@@ -947,7 +994,7 @@ MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the
|
||||
|
||||
#### YOLO-NAS
|
||||
|
||||
The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage).
|
||||
The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).
|
||||
|
||||
**Note:** The default model for the MemryX detector is YOLO-NAS 320x320.
|
||||
|
||||
@@ -981,7 +1028,7 @@ model:
|
||||
|
||||
#### YOLOv9
|
||||
|
||||
The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage).
|
||||
The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).
|
||||
|
||||
##### Configuration
|
||||
|
||||
@@ -1063,19 +1110,39 @@ model:
|
||||
|
||||
#### Using a Custom Model
|
||||
|
||||
To use your own model:
|
||||
To use your own custom model, first compile it into a [.dfp](https://developer.memryx.com/2p1/specs/files.html#dataflow-program) file, which is the format used by MemryX.
|
||||
|
||||
1. Package your compiled model into a `.zip` file.
|
||||
#### Compile the Model
|
||||
|
||||
2. The `.zip` must contain the compiled `.dfp` file.
|
||||
Custom models must be compiled using **MemryX SDK 2.1**.
|
||||
|
||||
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`.
|
||||
Before compiling your model, install the MemryX Neural Compiler tools from the
|
||||
[Install Tools](https://developer.memryx.com/2p1/get_started/install_tools.html) page on the **host**.
|
||||
|
||||
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config.
|
||||
> **Note:** It is recommended to compile the model on the host machine, or on another separate machine, rather than inside the Frigate Docker container. Installing the compiler inside Docker may conflict with container packages. It is recommended to create a Python virtual environment and install the compiler there.
|
||||
|
||||
5. Update the `labelmap_path` to match your custom model's labels.
|
||||
Once the SDK 2.1 environment is set up, follow the
|
||||
[MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) documentation to compile your model.
|
||||
|
||||
For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/tutorials/tutorials.html).
|
||||
Example:
|
||||
|
||||
```bash
|
||||
mx_nc -m yolonas.onnx -c 4 --autocrop -v --dfp_fname yolonas.dfp
|
||||
```
|
||||
|
||||
For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/2p1/tutorials/tutorials.html).
|
||||
|
||||
#### Package the Compiled Model
|
||||
|
||||
1. Package your compiled model into a `.zip` file.
|
||||
|
||||
2. The `.zip` file must contain the compiled `.dfp` file.
|
||||
|
||||
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`.
|
||||
|
||||
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config.
|
||||
|
||||
5. Update `labelmap_path` to match your custom model's labels.
|
||||
|
||||
```yaml
|
||||
# The detector automatically selects the default model if nothing is provided in the config.
|
||||
@@ -1512,6 +1579,49 @@ COPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL
|
||||
EOF
|
||||
```
|
||||
|
||||
### Downloading DEIMv2 Model
|
||||
|
||||
[DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families:
|
||||
|
||||
- **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n`
|
||||
- **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x`
|
||||
|
||||
Set `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`).
|
||||
|
||||
```sh
|
||||
docker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF'
|
||||
FROM python:3.11-slim AS build
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
|
||||
WORKDIR /deimv2
|
||||
RUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git .
|
||||
# Install CPU-only PyTorch first to avoid pulling CUDA variant
|
||||
RUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||
RUN uv pip install --no-cache --system -r requirements.txt
|
||||
RUN uv pip install --no-cache --system onnx safetensors huggingface_hub
|
||||
RUN mkdir -p output
|
||||
ARG BACKBONE
|
||||
ARG MODEL_SIZE
|
||||
# Download from Hugging Face and convert safetensors to pth
|
||||
RUN python3 -c "\
|
||||
from huggingface_hub import hf_hub_download; \
|
||||
from safetensors.torch import load_file; \
|
||||
import torch; \
|
||||
backbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \
|
||||
size = '${MODEL_SIZE}'.upper(); \
|
||||
st = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \
|
||||
torch.save({'model': st}, 'output/deimv2.pth')"
|
||||
RUN sed -i "s/data = torch.rand(2/data = torch.rand(1/" tools/deployment/export_onnx.py
|
||||
# HuggingFace safetensors omits frozen constants that the model constructor initializes
|
||||
RUN sed -i "s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/" tools/deployment/export_onnx.py
|
||||
RUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth
|
||||
FROM scratch
|
||||
ARG BACKBONE
|
||||
ARG MODEL_SIZE
|
||||
COPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx
|
||||
EOF
|
||||
```
|
||||
|
||||
### Downloading RF-DETR Model
|
||||
|
||||
RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size.
|
||||
|
||||
@@ -68,7 +68,7 @@ record:
|
||||
|
||||
## Will Frigate delete old recordings if my storage runs out?
|
||||
|
||||
As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted.
|
||||
If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible.
|
||||
|
||||
## Configuring Recording Retention
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -206,7 +208,7 @@ Enabling arbitrary exec sources allows execution of arbitrary commands through g
|
||||
|
||||
## Advanced Restream Configurations
|
||||
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.10#source-exec) source in go2rtc can be used for custom ffmpeg commands and other applications. An example is below:
|
||||
|
||||
:::warning
|
||||
|
||||
@@ -214,16 +216,11 @@ The `exec:`, `echo:`, and `expr:` sources are disabled by default for security.
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
|
||||
The `exec:`, `echo:`, and `expr:` sources are disabled by default for security. You must set `GO2RTC_ALLOW_ARBITRARY_EXEC=true` to use them. See [Security: Restricted Stream Sources](#security-restricted-stream-sources) for more information.
|
||||
|
||||
:::
|
||||
|
||||
NOTE: The output will need to be passed with two curly braces `{{output}}`
|
||||
NOTE: RTSP output will need to be passed with two curly braces `{{output}}`, whereas pipe output must be passed without curly braces.
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {{output}}
|
||||
stream2: exec:rpicam-vid -t 0 --libav-format h264 -o -
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,11 +3,11 @@ 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
|
||||
|
||||
If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started#configuring-frigate) to configure Frigate.
|
||||
If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started.md#configuring-frigate) to configure Frigate.
|
||||
|
||||
:::
|
||||
|
||||
@@ -297,7 +297,7 @@ The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVM
|
||||
|
||||
#### Installation
|
||||
|
||||
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/get_started/hardware_setup.html).
|
||||
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/2p1/get_started/install_hardware.html).
|
||||
|
||||
Then follow these steps for installing the correct driver/runtime configuration:
|
||||
|
||||
@@ -306,6 +306,12 @@ Then follow these steps for installing the correct driver/runtime configuration:
|
||||
3. Run the script with `./user_installation.sh`
|
||||
4. **Restart your computer** to complete driver installation.
|
||||
|
||||
:::warning
|
||||
|
||||
For manual setup, use **MemryX SDK 2.1** only. Other SDK versions are not supported for this setup. See the [SDK 2.1 documentation](https://developer.memryx.com/2p1/index.html)
|
||||
|
||||
:::
|
||||
|
||||
#### Setup
|
||||
|
||||
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Updating
|
||||
|
||||
# Updating Frigate
|
||||
|
||||
The current stable version of Frigate is **0.17.0**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.0).
|
||||
The current stable version of Frigate is **0.17.2**. The release notes and any breaking changes for this version can be found on the [Frigate GitHub releases page](https://github.com/blakeblackshear/frigate/releases/tag/v0.17.2).
|
||||
|
||||
Keeping Frigate up to date ensures you benefit from the latest features, performance improvements, and bug fixes. The update process varies slightly depending on your installation method (Docker, Home Assistant App, etc.). Below are instructions for the most common setups.
|
||||
|
||||
@@ -31,21 +31,21 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
|
||||
|
||||
2. **Update and Pull the Latest Image**:
|
||||
- If using Docker Compose:
|
||||
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.0` instead of `0.16.4`). For example:
|
||||
- Edit your `docker-compose.yml` file to specify the desired version tag (e.g., `0.17.2` instead of `0.16.4`). For example:
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
image: ghcr.io/blakeblackshear/frigate:0.17.0
|
||||
image: ghcr.io/blakeblackshear/frigate:0.17.2
|
||||
```
|
||||
- Then pull the image:
|
||||
```bash
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.17.0
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.17.2
|
||||
```
|
||||
- **Note for `stable` Tag Users**: If your `docker-compose.yml` uses the `stable` tag (e.g., `ghcr.io/blakeblackshear/frigate:stable`), you don’t need to update the tag manually. The `stable` tag always points to the latest stable release after pulling.
|
||||
- If using `docker run`:
|
||||
- Pull the image with the appropriate tag (e.g., `0.17.0`, `0.17.0-tensorrt`, or `stable`):
|
||||
- Pull the image with the appropriate tag (e.g., `0.17.2`, `0.17.2-tensorrt`, or `stable`):
|
||||
```bash
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.17.0
|
||||
docker pull ghcr.io/blakeblackshear/frigate:0.17.2
|
||||
```
|
||||
|
||||
3. **Start the Container**:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -21,7 +21,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
|
||||
|
||||
### Can I keep using my Frigate+ models even if I do not renew my subscription?
|
||||
|
||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
|
||||
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models you train during an active subscription remain licensed for your continued use even after your subscription ends — models already in your model cache will keep working indefinitely. An active subscription is required to train new models and download new versions.
|
||||
|
||||
### Can I use Frigate+ models commercially?
|
||||
|
||||
A standard subscription covers use on camera systems you own or operate, including for your business. A shop, restaurant, warehouse, or office running Frigate+ at its own locations (including multiple locations) is exactly the kind of use the subscription is for.
|
||||
What the standard subscription does not cover is using Frigate+ models to provide a product or service to others. If you're deploying models at your customers' sites, bundling them with hardware you sell, or running them as part of a hosted or managed service, even if your customers never receive the model files themselves, you'll need a commercial license.
|
||||
Note that professional installers are fine under standard subscriptions when each customer holds their own Frigate+ subscription. The commercial license is for cases where your license powers your customers' sites.
|
||||
|
||||
### Why can't I submit images to Frigate+?
|
||||
|
||||
|
||||
@@ -65,11 +65,11 @@ Some users may find that Frigate+ models result in more false positives initiall
|
||||
|
||||
Frigate+ models support a more relevant set of objects for security cameras. The labels for annotation in Frigate+ are configurable by editing the camera in the Cameras section of Frigate+. Currently, the following objects are supported:
|
||||
|
||||
- **People**: `person`, `face`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `license_plate`
|
||||
- **People**: `person`, `face`, `baby`
|
||||
- **Vehicles**: `car`, `motorcycle`, `bicycle`, `boat`, `school_bus`, `garbage truck`, `license_plate`
|
||||
- **Delivery Logos**: `amazon`, `usps`, `ups`, `fedex`, `dhl`, `an_post`, `purolator`, `postnl`, `nzpost`, `postnord`, `gls`, `dpd`, `canada_post`, `royal_mail`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`
|
||||
- **Animals**: `dog`, `cat`, `deer`, `horse`, `bird`, `raccoon`, `fox`, `bear`, `cow`, `squirrel`, `goat`, `rabbit`, `skunk`, `kangaroo`, `possum`, `rodent`
|
||||
- **Other**: `package`, `waste_bin`, `bbq_grill`, `robot_lawnmower`, `umbrella`, `baby_stroller`
|
||||
|
||||
Other object types available in the default Frigate model are not available. Additional object types will be added in future releases.
|
||||
|
||||
@@ -77,9 +77,12 @@ Other object types available in the default Frigate model are not available. Add
|
||||
|
||||
Candidate labels are also available for annotation. These labels don't have enough data to be included in the model yet, but using them will help add support sooner. You can enable these labels by editing the camera settings.
|
||||
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `baby` labels are mapped to `person` until support for new labels is added.
|
||||
Where possible, these labels are mapped to existing labels during training. For example, any `duck` labels are mapped to `bird` until support for new labels is added.
|
||||
|
||||
The candidate labels are: `baby`, `bpost`, `badger`, `possum`, `rodent`, `chicken`, `groundhog`, `boar`, `hedgehog`, `tractor`, `golf cart`, `garbage truck`, `bus`, `sports ball`, `la_poste`, `lawnmower`, `heron`, `rickshaw`, `wombat`, `auspost`, `aramex`, `bobcat`, `mustelid`, `transoflex`, `airplane`, `drone`, `mountain_lion`, `crocodile`, `turkey`, `baby_stroller`, `monkey`, `coyote`, `porcupine`, `parcelforce`, `sheep`, `snake`, `helicopter`, `lizard`, `duck`, `hermes`, `cargus`, `fan_courier`, `sameday`
|
||||
- **Vehicles**: `tractor`, `golf_cart`, `bus`, `airplane`, `helicopter`, `rickshaw`, `scooter`
|
||||
- **Delivery Logos**: `bpost`, `auspost`, `aramex`, `transoflex`, `parcelforce`, `hermes`, `cargus`, `fan_courier`, `sameday`, `la_poste`
|
||||
- **Animals**: `badger`, `chicken`, `duck`, `turkey`, `groundhog`, `boar`, `hedgehog`, `wombat`, `bobcat`, `mustelid`, `mountain_lion`, `crocodile`, `monkey`, `coyote`, `porcupine`, `sheep`, `snake`, `lizard`, `heron`, `elk`, `moose`, `pig`, `donkey`, `civet`
|
||||
- **Other**: `sports_ball`, `drone`, `lawnmower`
|
||||
|
||||
Candidate labels are not available for automatic suggestions.
|
||||
|
||||
|
||||
@@ -110,3 +110,19 @@ No. Frigate uses the TCP protocol to connect to your camera's RTSP URL. VLC auto
|
||||
TCP ensures that all data packets arrive in the correct order. This is crucial for video recording, decoding, and stream processing, which is why Frigate enforces a TCP connection. UDP is faster but less reliable, as it does not guarantee packet delivery or order, and VLC does not have the same requirements as Frigate.
|
||||
|
||||
You can still configure Frigate to use UDP by using ffmpeg input args or the preset `preset-rtsp-udp`. See the [ffmpeg presets](/configuration/ffmpeg_presets) documentation.
|
||||
|
||||
### Why does Frigate keep creating new events for my parked car?
|
||||
|
||||
Stationary tracking is designed to _prevent_ this — a parked car should stay one tracked object and not generate new events. If you're getting repeated events for the same car, it's likely that Frigate is losing the tracked object and re-detecting it as a new one.
|
||||
|
||||
Open one of the events in Explore → **Tracking Details**. If the detection scores are low (< 70% or so), the model isn't confident the parked car is a car. This is common with the free [COCO-trained](https://cocodataset.org/#explore) object detection models on steep/top-down angles, partially occluded cars, foliage, or low-light footage. When detections fall below `min_score` for too many frames the tracker loses the object, and the next confident frame creates a brand new one.
|
||||
|
||||
What helps:
|
||||
|
||||
- **Improve the view** — even a small angle change that gets more of the car visible could lift scores enough to stabilize tracking.
|
||||
- **Use a more accurate model** — switching from `mobiledet` to `yolov9`, or stepping up to a larger variant like `yolov9-s` over `yolov9-t`, can help (at the cost of inference time, and still on the COCO dataset). The biggest gains usually come from fine-tuning a model on images from your own cameras so it learns your specific scene. [Frigate+](https://frigate.video/plus) is a paid option that does this - models are trained on security-camera footage and can be fine-tuned on images you submit from your own setup.
|
||||
- **Don't set `detect -> stationary -> max_frames` for `car`** — it artificially ends tracking and forces re-detection as a new object. See [Stationary Objects](../configuration/stationary_objects.md).
|
||||
- **Restrict alerts to the areas you care about** with `required_zones` — see [Zones](../configuration/zones.md#restricting-alerts-and-detections-to-specific-zones). Make sure those zones use the default `loitering_time: 0` unless you specifically want the review item to stay open until the car leaves.
|
||||
- **Filter impossible locations** with [object filter masks](../configuration/masks.md#object-filter-masks) if cars are being detected on rooftops, treetops, etc.
|
||||
|
||||
See [Object Filters](../configuration/object_filters.md) for more on tuning `min_score` and `threshold` — note that raising them too high will make this exact problem worse.
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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
@@ -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})
|
||||
|
||||
|
||||
|
||||
+14
-1
@@ -24,7 +24,7 @@ from frigate.api.defs.tags import Tags
|
||||
from frigate.config.config import FrigateConfig
|
||||
from frigate.util.builtin import clean_camera_user_pass
|
||||
from frigate.util.image import run_ffmpeg_snapshot
|
||||
from frigate.util.services import ffprobe_stream
|
||||
from frigate.util.services import ffprobe_stream, is_restricted_go2rtc_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -111,6 +111,19 @@ def go2rtc_camera_stream(request: Request, stream_name: str):
|
||||
)
|
||||
def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
|
||||
"""Add or update a go2rtc stream configuration."""
|
||||
if src and is_restricted_go2rtc_source(src):
|
||||
logger.warning(
|
||||
"Rejected go2rtc stream '%s' with restricted source type (echo/expr/exec)",
|
||||
stream_name,
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Restricted stream source type",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
params = {"name": stream_name}
|
||||
if src:
|
||||
|
||||
@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field
|
||||
from pydantic.json_schema import SkipJsonSchema
|
||||
|
||||
from frigate.record.export import (
|
||||
ChaptersEnum,
|
||||
PlaybackFactorEnum,
|
||||
PlaybackSourceEnum,
|
||||
)
|
||||
@@ -18,3 +19,11 @@ class ExportRecordingsBody(BaseModel):
|
||||
)
|
||||
name: Optional[str] = Field(title="Friendly name", default=None, max_length=256)
|
||||
image_path: Union[str, SkipJsonSchema[None]] = None
|
||||
chapters: Optional[ChaptersEnum] = Field(
|
||||
default=None,
|
||||
title="Chapter mode",
|
||||
description=(
|
||||
"Optional chapter metadata to embed in the export. When omitted, "
|
||||
"no chapter track is added."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -91,8 +91,26 @@ def export_recording(
|
||||
playback_factor = body.playback
|
||||
playback_source = body.source
|
||||
friendly_name = body.name
|
||||
|
||||
# sanitize_filepath normalizes "\" to "/" but leaves ".." intact, so a path
|
||||
# like "clips\..\..\etc/passwd" passes the CLIPS_DIR prefix check yet still
|
||||
# escapes the directory once resolved. A valid snapshot path never uses "..".
|
||||
if body.image_path and ".." in body.image_path:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Invalid image path"}),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
existing_image = sanitize_filepath(body.image_path) if body.image_path else None
|
||||
|
||||
# a chapters value in the request body overrides the camera's export config
|
||||
camera_config = request.app.frigate_config.cameras[camera_name]
|
||||
chapters = (
|
||||
body.chapters
|
||||
if body.chapters is not None
|
||||
else camera_config.record.export.chapters
|
||||
)
|
||||
|
||||
# Ensure that existing_image is a valid path
|
||||
if existing_image and not existing_image.startswith(CLIPS_DIR):
|
||||
return JSONResponse(
|
||||
@@ -161,6 +179,7 @@ def export_recording(
|
||||
if playback_source in PlaybackSourceEnum.__members__.values()
|
||||
else PlaybackSourceEnum.recordings
|
||||
),
|
||||
chapters=chapters,
|
||||
)
|
||||
exporter.start()
|
||||
return JSONResponse(
|
||||
|
||||
+90
-52
@@ -59,6 +59,19 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=[Tags.media])
|
||||
|
||||
|
||||
def _resolve_cache_age(max_cache_age: int) -> int:
|
||||
"""Return max_cache_age as an int.
|
||||
|
||||
When a media handler is invoked directly by another handler instead of
|
||||
through its route, FastAPI doesn't resolve the Query() default and
|
||||
max_cache_age arrives as the Query object; fall back to its int default.
|
||||
"""
|
||||
if isinstance(max_cache_age, int):
|
||||
return max_cache_age
|
||||
|
||||
return max_cache_age.default
|
||||
|
||||
|
||||
@router.get("/{camera_name}", dependencies=[Depends(require_camera_access)])
|
||||
async def mjpeg_feed(
|
||||
request: Request,
|
||||
@@ -380,7 +393,9 @@ async def submit_recording_snapshot_to_plus(
|
||||
)
|
||||
|
||||
nd = cv2.imdecode(np.frombuffer(image_data, dtype=np.int8), cv2.IMREAD_COLOR)
|
||||
request.app.frigate_config.plus_api.upload_image(nd, camera_name)
|
||||
await asyncio.to_thread(
|
||||
request.app.frigate_config.plus_api.upload_image, nd, camera_name
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
@@ -1142,7 +1157,6 @@ async def event_snapshot(
|
||||
|
||||
@router.get(
|
||||
"/events/{event_id}/thumbnail.{extension}",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
async def event_thumbnail(
|
||||
request: Request,
|
||||
@@ -1186,11 +1200,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,
|
||||
@@ -1200,20 +1215,20 @@ 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,
|
||||
media_type=extension.get_mime_type(),
|
||||
headers={
|
||||
"Cache-Control": f"private, max-age={max_cache_age}"
|
||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}"
|
||||
if event_complete
|
||||
else "no-store",
|
||||
},
|
||||
@@ -1343,12 +1358,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(
|
||||
@@ -1469,7 +1484,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,
|
||||
@@ -1483,6 +1498,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
|
||||
@@ -1499,9 +1516,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:
|
||||
@@ -1509,18 +1526,20 @@ 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
|
||||
)
|
||||
return preview_gif(request, event.camera, start_ts, end_ts)
|
||||
return await preview_gif(request, event.camera, start_ts, end_ts)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.gif",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def preview_gif(
|
||||
async def preview_gif(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
start_ts: float,
|
||||
@@ -1531,25 +1550,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,
|
||||
@@ -1583,7 +1602,8 @@ def preview_gif(
|
||||
"-",
|
||||
]
|
||||
|
||||
process = sp.run(
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -1650,7 +1670,8 @@ def preview_gif(
|
||||
"-",
|
||||
]
|
||||
|
||||
process = sp.run(
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
ffmpeg_cmd,
|
||||
input=str.encode("\n".join(selected_previews)),
|
||||
capture_output=True,
|
||||
@@ -1669,7 +1690,7 @@ def preview_gif(
|
||||
gif_bytes,
|
||||
media_type="image/gif",
|
||||
headers={
|
||||
"Cache-Control": f"private, max-age={max_cache_age}",
|
||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
|
||||
"Content-Type": "image/gif",
|
||||
},
|
||||
)
|
||||
@@ -1679,7 +1700,7 @@ def preview_gif(
|
||||
"/{camera_name}/start/{start_ts}/end/{end_ts}/preview.mp4",
|
||||
dependencies=[Depends(require_camera_access)],
|
||||
)
|
||||
def preview_mp4(
|
||||
async def preview_mp4(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
start_ts: float,
|
||||
@@ -1759,7 +1780,8 @@ def preview_mp4(
|
||||
path,
|
||||
]
|
||||
|
||||
process = sp.run(
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
)
|
||||
@@ -1823,7 +1845,8 @@ def preview_mp4(
|
||||
path,
|
||||
]
|
||||
|
||||
process = sp.run(
|
||||
process = await asyncio.to_thread(
|
||||
sp.run,
|
||||
ffmpeg_cmd,
|
||||
input=str.encode("\n".join(selected_previews)),
|
||||
capture_output=True,
|
||||
@@ -1838,7 +1861,7 @@ def preview_mp4(
|
||||
|
||||
headers = {
|
||||
"Content-Description": "File Transfer",
|
||||
"Cache-Control": f"private, max-age={max_cache_age}",
|
||||
"Cache-Control": f"private, max-age={_resolve_cache_age(max_cache_age)}",
|
||||
"Content-Type": "video/mp4",
|
||||
"Content-Length": str(os.path.getsize(path)),
|
||||
# nginx: https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_ignore_headers
|
||||
@@ -1853,8 +1876,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"]),
|
||||
@@ -1867,6 +1890,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 = (
|
||||
@@ -1874,18 +1899,20 @@ def review_preview(
|
||||
)
|
||||
|
||||
if format == "gif":
|
||||
return preview_gif(request, review.camera, start_ts, end_ts)
|
||||
return await preview_gif(request, review.camera, start_ts, end_ts)
|
||||
else:
|
||||
return preview_mp4(request, review.camera, start_ts, end_ts)
|
||||
return await preview_mp4(request, review.camera, start_ts, end_ts)
|
||||
|
||||
|
||||
@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(
|
||||
@@ -1895,6 +1922,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
@@ -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,
|
||||
|
||||
+132
-3
@@ -17,9 +17,117 @@ from ws4py.websocket import WebSocket as WebSocket_
|
||||
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import (
|
||||
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
INSERT_MANY_RECORDINGS,
|
||||
INSERT_PREVIEW,
|
||||
NOTIFICATION_TEST,
|
||||
REQUEST_REGION_GRID,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
from frigate.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Internal IPC topics — NEVER allowed from WebSocket, regardless of role
|
||||
_WS_BLOCKED_TOPICS = frozenset(
|
||||
{
|
||||
INSERT_MANY_RECORDINGS,
|
||||
INSERT_PREVIEW,
|
||||
REQUEST_REGION_GRID,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
NOTIFICATION_TEST,
|
||||
}
|
||||
)
|
||||
|
||||
# Read-only topics any authenticated user (including viewer) can send
|
||||
_WS_VIEWER_TOPICS = frozenset(
|
||||
{
|
||||
"onConnect",
|
||||
"modelState",
|
||||
"audioTranscriptionState",
|
||||
"birdseyeLayout",
|
||||
"embeddingsReindexProgress",
|
||||
}
|
||||
)
|
||||
|
||||
# Camera-scoped command topics a camera-authorized (non-admin) user may send.
|
||||
_WS_CAMERA_COMMAND_TOPICS = frozenset({"ptz"})
|
||||
|
||||
|
||||
def _check_ws_authorization(
|
||||
topic: str,
|
||||
role_header: str | None,
|
||||
separator: str,
|
||||
roles_config: dict[str, list[str]] | None = None,
|
||||
camera_names: set[str] | None = None,
|
||||
) -> bool:
|
||||
"""Check if a WebSocket message is authorized.
|
||||
|
||||
Args:
|
||||
topic: The message topic.
|
||||
role_header: The HTTP_REMOTE_ROLE header value, or None.
|
||||
separator: The role separator character from proxy config.
|
||||
roles_config: The auth.roles mapping (role -> allowed cameras), used to
|
||||
authorize camera-scoped commands for non-admin users.
|
||||
camera_names: All configured camera names, used to resolve a role's
|
||||
allowed cameras.
|
||||
|
||||
Returns:
|
||||
True if authorized, False if blocked.
|
||||
"""
|
||||
# Block IPC-only topics unconditionally
|
||||
if topic in _WS_BLOCKED_TOPICS:
|
||||
return False
|
||||
|
||||
# No role header: default to viewer (fail-closed)
|
||||
roles = [r.strip() for r in role_header.split(separator)] if role_header else []
|
||||
|
||||
# Admin can send anything
|
||||
if "admin" in roles:
|
||||
return True
|
||||
|
||||
# Read-only topics any authenticated user can send
|
||||
if topic in _WS_VIEWER_TOPICS:
|
||||
return True
|
||||
|
||||
# Camera-scoped command like "<camera>/ptz": allow when the user's role(s)
|
||||
# grant access to that camera.
|
||||
parts = topic.split("/")
|
||||
if (
|
||||
roles_config is not None
|
||||
and len(parts) == 2
|
||||
and parts[1] in _WS_CAMERA_COMMAND_TOPICS
|
||||
):
|
||||
allowed: set[str] = set()
|
||||
# No role header maps to the default viewer role (e.g. proxy-only setups)
|
||||
for role in roles or ["viewer"]:
|
||||
allowed.update(
|
||||
User.get_allowed_cameras(role, roles_config, camera_names or set())
|
||||
)
|
||||
return parts[0] in allowed
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class WebSocket(WebSocket_): # type: ignore[misc]
|
||||
def unhandled_error(self, error: Any) -> None:
|
||||
@@ -49,6 +157,9 @@ class WebSocketClient(Communicator):
|
||||
|
||||
class _WebSocketHandler(WebSocket):
|
||||
receiver = self._dispatcher
|
||||
role_separator = self.config.proxy.separator or ","
|
||||
roles_config = self.config.auth.roles
|
||||
camera_names = set(self.config.cameras.keys())
|
||||
|
||||
def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined]
|
||||
try:
|
||||
@@ -63,11 +174,29 @@ class WebSocketClient(Communicator):
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Publishing mqtt message from websockets at {json_message['topic']}."
|
||||
topic = json_message["topic"]
|
||||
|
||||
# Authorization check (skip when environ is None — direct internal connection)
|
||||
role_header = (
|
||||
self.environ.get("HTTP_REMOTE_ROLE") if self.environ else None
|
||||
)
|
||||
if self.environ is not None and not _check_ws_authorization(
|
||||
topic,
|
||||
role_header,
|
||||
self.role_separator,
|
||||
self.roles_config,
|
||||
self.camera_names,
|
||||
):
|
||||
logger.warning(
|
||||
"Blocked unauthorized WebSocket message: topic=%s, role=%s",
|
||||
topic,
|
||||
role_header,
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(f"Publishing mqtt message from websockets at {topic}.")
|
||||
self.receiver(
|
||||
json_message["topic"],
|
||||
topic,
|
||||
json_message["payload"],
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -9,6 +9,7 @@ from frigate.review.types import SeverityEnum
|
||||
from ..base import FrigateBaseModel
|
||||
|
||||
__all__ = [
|
||||
"ChaptersEnum",
|
||||
"RecordConfig",
|
||||
"RecordExportConfig",
|
||||
"RecordPreviewConfig",
|
||||
@@ -66,10 +67,19 @@ class RecordPreviewConfig(FrigateBaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChaptersEnum(str, Enum):
|
||||
none = "none"
|
||||
recording_segments = "recording_segments"
|
||||
|
||||
|
||||
class RecordExportConfig(FrigateBaseModel):
|
||||
timelapse_args: str = Field(
|
||||
default=DEFAULT_TIME_LAPSE_FFMPEG_ARGS, title="Timelapse Args"
|
||||
)
|
||||
chapters: ChaptersEnum = Field(
|
||||
default=ChaptersEnum.none,
|
||||
title="Chapter metadata to embed in exported recordings",
|
||||
)
|
||||
|
||||
|
||||
class RecordConfig(FrigateBaseModel):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
+140
-5
@@ -12,9 +12,11 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytz
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.config import FfmpegConfig, FrigateConfig
|
||||
from frigate.config.camera.record import ChaptersEnum
|
||||
from frigate.const import (
|
||||
CACHE_DIR,
|
||||
CLIPS_DIR,
|
||||
@@ -64,6 +66,7 @@ class RecordingExporter(threading.Thread):
|
||||
end_time: int,
|
||||
playback_factor: PlaybackFactorEnum,
|
||||
playback_source: PlaybackSourceEnum,
|
||||
chapters: Optional[ChaptersEnum] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
@@ -75,6 +78,7 @@ class RecordingExporter(threading.Thread):
|
||||
self.end_time = end_time
|
||||
self.playback_factor = playback_factor
|
||||
self.playback_source = playback_source
|
||||
self.chapters = chapters
|
||||
|
||||
# ensure export thumb dir
|
||||
Path(os.path.join(CLIPS_DIR, "export")).mkdir(exist_ok=True)
|
||||
@@ -83,6 +87,77 @@ class RecordingExporter(threading.Thread):
|
||||
# return in iso format
|
||||
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _chapter_metadata_path(self) -> str:
|
||||
return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt")
|
||||
|
||||
def _build_recording_segment_chapter_metadata_file(
|
||||
self, recordings: list
|
||||
) -> Optional[str]:
|
||||
"""Write an FFmpeg metadata file with one chapter per recording segment.
|
||||
|
||||
Each chapter's title is the segment's wallclock start time in
|
||||
strict ISO 8601 form so a viewer can map any point in the
|
||||
export's playback timeline back to real-world time without
|
||||
OCR-ing a burnt-in timestamp. Chapter offsets are computed in
|
||||
*output time*: the VOD endpoint concatenates recording clips
|
||||
back-to-back, so wall-clock gaps between recordings collapse in
|
||||
the produced video. Returns ``None`` when there are no
|
||||
recordings or every segment is empty after clipping.
|
||||
"""
|
||||
if not recordings:
|
||||
return None
|
||||
|
||||
tz_name = self.config.ui.timezone
|
||||
tz: Optional[datetime.tzinfo] = None
|
||||
if tz_name:
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = None
|
||||
if tz is None:
|
||||
tz = datetime.timezone.utc
|
||||
|
||||
chapter_blocks: list[str] = []
|
||||
output_offset_ms = 0
|
||||
for rec in recordings:
|
||||
clipped_start = max(float(rec.start_time), float(self.start_time))
|
||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||
if clipped_end <= clipped_start:
|
||||
continue
|
||||
|
||||
duration_ms = int(round((clipped_end - clipped_start) * 1000))
|
||||
if duration_ms <= 0:
|
||||
continue
|
||||
|
||||
title = datetime.datetime.fromtimestamp(clipped_start, tz=tz).isoformat(
|
||||
timespec="seconds"
|
||||
)
|
||||
chapter_blocks.append(
|
||||
"[CHAPTER]\n"
|
||||
"TIMEBASE=1/1000\n"
|
||||
f"START={output_offset_ms}\n"
|
||||
f"END={output_offset_ms + duration_ms}\n"
|
||||
f"title={title}"
|
||||
)
|
||||
output_offset_ms += duration_ms
|
||||
|
||||
if not chapter_blocks:
|
||||
return None
|
||||
|
||||
meta_path = self._chapter_metadata_path()
|
||||
try:
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
f.write(";FFMETADATA1\n")
|
||||
f.write("\n".join(chapter_blocks))
|
||||
f.write("\n")
|
||||
except OSError:
|
||||
logger.exception(
|
||||
"Failed to write chapter metadata file for export %s", self.export_id
|
||||
)
|
||||
return None
|
||||
|
||||
return meta_path
|
||||
|
||||
def save_thumbnail(self, id: str) -> str:
|
||||
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
|
||||
|
||||
@@ -218,9 +293,41 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
|
||||
|
||||
# When chapters are requested, query the per-segment recording rows
|
||||
# and write an FFmpeg metadata sidecar. Timelapse playback rescales
|
||||
# time so chapter offsets would no longer match wallclock — restrict
|
||||
# chapter injection to realtime playback.
|
||||
chapter_args = ""
|
||||
if (
|
||||
self.chapters == ChaptersEnum.recording_segments
|
||||
and self.playback_factor == PlaybackFactorEnum.realtime
|
||||
):
|
||||
recordings = list(
|
||||
Recordings.select(
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(self.start_time, self.end_time)
|
||||
| Recordings.end_time.between(self.start_time, self.end_time)
|
||||
| (
|
||||
(self.start_time > Recordings.start_time)
|
||||
& (self.end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(Recordings.camera == self.camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.iterator()
|
||||
)
|
||||
chapters_path = self._build_recording_segment_chapter_metadata_file(
|
||||
recordings
|
||||
)
|
||||
if chapters_path:
|
||||
chapter_args = f" -i {chapters_path} -map 0 -dn -map_metadata 1"
|
||||
|
||||
if self.playback_factor == PlaybackFactorEnum.realtime:
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart"
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
|
||||
).split(" ")
|
||||
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
|
||||
ffmpeg_cmd = (
|
||||
@@ -235,7 +342,19 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
# add metadata
|
||||
title = f"Frigate Recording for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}"
|
||||
ffmpeg_cmd.extend(["-metadata", f"title={title}"])
|
||||
creation_time = datetime.datetime.fromtimestamp(
|
||||
self.start_time, tz=datetime.timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
ffmpeg_cmd.extend(
|
||||
[
|
||||
"-metadata",
|
||||
f"title={title}",
|
||||
"-metadata",
|
||||
f"creation_time={creation_time}",
|
||||
"-metadata",
|
||||
f"comment=Camera: {self.camera}",
|
||||
]
|
||||
)
|
||||
|
||||
ffmpeg_cmd.append(video_path)
|
||||
|
||||
@@ -311,7 +430,7 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
if self.playback_factor == PlaybackFactorEnum.realtime:
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart {video_path}"
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} {codec} -movflags +faststart"
|
||||
).split(" ")
|
||||
elif self.playback_factor == PlaybackFactorEnum.timelapse_25x:
|
||||
ffmpeg_cmd = (
|
||||
@@ -319,14 +438,28 @@ class RecordingExporter(threading.Thread):
|
||||
self.config.ffmpeg.ffmpeg_path,
|
||||
self.config.ffmpeg.hwaccel_args,
|
||||
f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}",
|
||||
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {video_path}",
|
||||
f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart",
|
||||
EncodeTypeEnum.timelapse,
|
||||
)
|
||||
).split(" ")
|
||||
|
||||
# add metadata
|
||||
title = f"Frigate Preview for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}"
|
||||
ffmpeg_cmd.extend(["-metadata", f"title={title}"])
|
||||
creation_time = datetime.datetime.fromtimestamp(
|
||||
self.start_time, tz=datetime.timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
ffmpeg_cmd.extend(
|
||||
[
|
||||
"-metadata",
|
||||
f"title={title}",
|
||||
"-metadata",
|
||||
f"creation_time={creation_time}",
|
||||
"-metadata",
|
||||
f"comment=Camera: {self.camera}",
|
||||
]
|
||||
)
|
||||
|
||||
ffmpeg_cmd.append(video_path)
|
||||
|
||||
return ffmpeg_cmd, playlist_lines
|
||||
|
||||
@@ -376,6 +509,8 @@ class RecordingExporter(threading.Thread):
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
Path(self._chapter_metadata_path()).unlink(missing_ok=True)
|
||||
|
||||
if p.returncode != 0:
|
||||
logger.error(
|
||||
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"
|
||||
|
||||
@@ -547,6 +547,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-metadata",
|
||||
f"creation_time={start_time.strftime('%Y-%m-%dT%H:%M:%S.%fZ')}",
|
||||
file_path,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
@@ -357,6 +358,51 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
|
||||
f"got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_add_stream_rejects_restricted_source(self):
|
||||
"""PUT /go2rtc/streams must reject exec:/echo:/expr: sources even for
|
||||
admins"""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
with AuthTestClient(app) as client:
|
||||
for src in (
|
||||
"exec:/tmp/rev.sh",
|
||||
"echo:foo",
|
||||
"expr:bar",
|
||||
" exec:/tmp/rev.sh",
|
||||
):
|
||||
resp = client.put(f"/go2rtc/streams/revshell?src={src}")
|
||||
assert resp.status_code == 400, (
|
||||
f"Expected 400 for restricted src {src!r}; got {resp.status_code}"
|
||||
)
|
||||
assert resp.json().get("success") is False
|
||||
|
||||
def test_add_stream_allows_non_restricted_source(self):
|
||||
"""A normal stream URL should pass the restricted-source check and reach
|
||||
the (unavailable in tests) go2rtc proxy — so we expect 500, not 400."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put("/go2rtc/streams/legit?src=rtsp://10.0.0.1:554/video")
|
||||
assert resp.status_code != 400, (
|
||||
f"Non-restricted source should not be rejected with 400; got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_add_stream_allows_restricted_source_when_override_set(self):
|
||||
"""When GO2RTC_ALLOW_ARBITRARY_EXEC is set, the API must defer to operator
|
||||
intent and forward the request to go2rtc instead of short-circuiting with 400."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
|
||||
with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
|
||||
with patch(
|
||||
"frigate.api.camera.requests.put", return_value=mock_response
|
||||
) as mock_put:
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.put("/go2rtc/streams/legit?src=exec:/tmp/something")
|
||||
assert resp.status_code == 200, (
|
||||
f"Restricted src should be forwarded when override set; got {resp.status_code}"
|
||||
)
|
||||
mock_put.assert_called_once()
|
||||
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
|
||||
assert forwarded_src == "exec:/tmp/something"
|
||||
|
||||
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
|
||||
"""limited_user cannot access a stream alias that belongs to a camera they
|
||||
are not allowed to see."""
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Tests for WebSocket authorization checks."""
|
||||
|
||||
import unittest
|
||||
|
||||
from frigate.comms.ws import _check_ws_authorization
|
||||
from frigate.const import INSERT_MANY_RECORDINGS, UPDATE_CAMERA_ACTIVITY
|
||||
|
||||
|
||||
class TestCheckWsAuthorization(unittest.TestCase):
|
||||
"""Tests for the _check_ws_authorization pure function."""
|
||||
|
||||
DEFAULT_SEPARATOR = ","
|
||||
|
||||
# admin/viewer are reserved and always map to all cameras (empty list);
|
||||
# custom roles map to a specific set of cameras.
|
||||
ROLES_CONFIG = {
|
||||
"admin": [],
|
||||
"viewer": [],
|
||||
"yard": ["front_door", "backyard"],
|
||||
"garage_only": ["garage"],
|
||||
}
|
||||
CAMERA_NAMES = {"front_door", "backyard", "garage"}
|
||||
|
||||
# --- IPC topic blocking (unconditional, regardless of role) ---
|
||||
|
||||
def test_ipc_topic_blocked_for_admin(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
INSERT_MANY_RECORDINGS, "admin", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_ipc_topic_blocked_for_viewer(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
UPDATE_CAMERA_ACTIVITY, "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_ipc_topic_blocked_when_no_role(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
INSERT_MANY_RECORDINGS, None, self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Viewer allowed topics ---
|
||||
|
||||
def test_viewer_can_send_on_connect(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("onConnect", "viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_viewer_can_send_model_state(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("modelState", "viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_viewer_can_send_audio_transcription_state(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"audioTranscriptionState", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_can_send_birdseye_layout(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("birdseyeLayout", "viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_viewer_can_send_embeddings_reindex_progress(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"embeddingsReindexProgress", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Viewer blocked from admin topics ---
|
||||
|
||||
def test_viewer_blocked_from_restart(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization("restart", "viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_camera_detect_set(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"front_door/detect/set", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_camera_ptz(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization("front_door/ptz", "viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_global_notifications_set(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"notifications/set", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_camera_notifications_suspend(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"front_door/notifications/suspend", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_arbitrary_unknown_topic(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"some_random_topic", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Admin access ---
|
||||
|
||||
def test_admin_can_send_restart(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("restart", "admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_admin_can_send_camera_detect_set(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"front_door/detect/set", "admin", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_admin_can_send_camera_ptz(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
# --- Comma-separated roles ---
|
||||
|
||||
def test_comma_separated_admin_viewer_grants_admin(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("restart", "admin,viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_comma_separated_viewer_admin_grants_admin(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("restart", "viewer,admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_comma_separated_with_spaces(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("restart", "viewer, admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
# --- Custom separator ---
|
||||
|
||||
def test_pipe_separator(self):
|
||||
self.assertTrue(_check_ws_authorization("restart", "viewer|admin", "|"))
|
||||
|
||||
def test_pipe_separator_no_admin(self):
|
||||
self.assertFalse(_check_ws_authorization("restart", "viewer|editor", "|"))
|
||||
|
||||
# --- No role header (fail-closed) ---
|
||||
|
||||
def test_no_role_header_blocks_admin_topics(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization("restart", None, self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_no_role_header_allows_viewer_topics(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("onConnect", None, self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
# --- Camera-scoped PTZ access (non-admin with camera access) ---
|
||||
|
||||
def test_viewer_can_ptz_camera_with_access(self):
|
||||
# viewer maps to all cameras, so PTZ is allowed
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"front_door/ptz",
|
||||
"viewer",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_custom_role_can_ptz_assigned_camera(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"front_door/ptz",
|
||||
"yard",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_custom_role_blocked_from_ptz_unassigned_camera(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"garage/ptz",
|
||||
"yard",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_multiple_roles_union_camera_access_for_ptz(self):
|
||||
# "yard" covers front_door/backyard, "garage_only" covers garage
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"garage/ptz",
|
||||
"yard,garage_only",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_unknown_role_blocked_from_ptz(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"front_door/ptz",
|
||||
"nonexistent",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_no_role_header_treated_as_viewer_for_ptz(self):
|
||||
# proxy-only / auth-disabled setups default to the viewer role
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"front_door/ptz",
|
||||
None,
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_camera_access_does_not_grant_set_commands(self):
|
||||
# camera access enables PTZ only, not config-changing "set" commands
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"front_door/detect/set",
|
||||
"yard",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_ptz_autotracker_stays_admin_only(self):
|
||||
# ptz_autotracker is a config toggle, not a live-view action
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"front_door/ptz_autotracker/set",
|
||||
"viewer",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_admin_can_ptz_any_camera_with_config(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"garage/ptz",
|
||||
"admin",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
def test_ipc_topic_still_blocked_with_camera_access(self):
|
||||
# IPC topics are blocked unconditionally, even with camera access
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
"viewer",
|
||||
self.DEFAULT_SEPARATOR,
|
||||
self.ROLES_CONFIG,
|
||||
self.CAMERA_NAMES,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
+9
-10
@@ -271,18 +271,17 @@ def get_min_region_size(model_config: ModelConfig) -> int:
|
||||
"""Get the min region size."""
|
||||
largest_dimension = max(model_config.height, model_config.width)
|
||||
|
||||
if largest_dimension > 320:
|
||||
# We originally tested allowing any model to have a region down to half of the model size
|
||||
# but this led to many false positives. In this case we specifically target larger models
|
||||
# which can benefit from a smaller region in some cases to detect smaller objects.
|
||||
half = int(largest_dimension / 2)
|
||||
# return largest dimension for smaller models, but make sure the dimension is normalized
|
||||
if largest_dimension < 320:
|
||||
if largest_dimension % 4 == 0:
|
||||
return largest_dimension
|
||||
|
||||
if half % 4 == 0:
|
||||
return half
|
||||
return int((largest_dimension + 3) / 4) * 4
|
||||
|
||||
return int((half + 3) / 4) * 4
|
||||
|
||||
return largest_dimension
|
||||
# Any model that is 320 or larger should have a minimum region size of 320
|
||||
# this allows larger models to use smaller regions to detect smaller objects
|
||||
# in the case that the motion area is smaller so that it can be upscaled.
|
||||
return 320
|
||||
|
||||
|
||||
def create_tensor_input(frame, model_config: ModelConfig, region):
|
||||
|
||||
@@ -556,6 +556,41 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
|
||||
return results
|
||||
|
||||
|
||||
def is_go2rtc_arbitrary_exec_allowed() -> bool:
|
||||
"""Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker
|
||||
secrets, or the Home Assistant add-on options file."""
|
||||
raw: Optional[str] = None
|
||||
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
|
||||
raw = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
|
||||
elif (
|
||||
os.path.isdir("/run/secrets")
|
||||
and os.access("/run/secrets", os.R_OK)
|
||||
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
|
||||
):
|
||||
try:
|
||||
with open("/run/secrets/GO2RTC_ALLOW_ARBITRARY_EXEC") as f:
|
||||
raw = f.read().strip()
|
||||
except OSError:
|
||||
raw = None
|
||||
elif os.path.isfile("/data/options.json"):
|
||||
try:
|
||||
with open("/data/options.json") as f:
|
||||
options = json.loads(f.read())
|
||||
raw = options.get("go2rtc_allow_arbitrary_exec")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
raw = None
|
||||
|
||||
return raw is not None and str(raw).lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
def is_restricted_go2rtc_source(stream_source: str) -> bool:
|
||||
"""Check if a stream source is a restricted type (echo, expr, or exec)
|
||||
and the GO2RTC_ALLOW_ARBITRARY_EXEC override is not set."""
|
||||
if not stream_source.strip().startswith(("echo:", "expr:", "exec:")):
|
||||
return False
|
||||
return not is_go2rtc_arbitrary_exec_allowed()
|
||||
|
||||
|
||||
def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess:
|
||||
"""Run ffprobe on stream."""
|
||||
clean_path = escape_special_characters(path)
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
@@ -1,88 +1,95 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "rmuF9iKWTbdk"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"! pip install -q git+https://github.com/Deci-AI/super-gradients.git"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "NiRCt917KKcL"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.12/dist-packages/super_gradients/training/pretrained_models.py\n",
|
||||
"! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.12/dist-packages/super_gradients/training/utils/checkpoint_utils.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "dTB0jy_NNSFz"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from super_gradients.common.object_names import Models\n",
|
||||
"from super_gradients.conversion import DetectionOutputFormatMode\n",
|
||||
"from super_gradients.training import models\n",
|
||||
"\n",
|
||||
"model = models.get(Models.YOLO_NAS_S, pretrained_weights=\"coco\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "GymUghyCNXem"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# export the model for compatibility with Frigate\n",
|
||||
"\n",
|
||||
"model.export(\"yolo_nas_s.onnx\",\n",
|
||||
" output_predictions_format=DetectionOutputFormatMode.FLAT_FORMAT,\n",
|
||||
" max_predictions_per_image=20,\n",
|
||||
" num_pre_nms_predictions=300,\n",
|
||||
" confidence_threshold=0.4,\n",
|
||||
" input_image_shape=(320,320),\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "uBhXV5g4Nh42"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from google.colab import files\n",
|
||||
"\n",
|
||||
"files.download('yolo_nas_s.onnx')"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"colab": {
|
||||
"provenance": []
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "runtime-notice"
|
||||
},
|
||||
"source": [
|
||||
"**Before running:** go to **Runtime → Change runtime type → Fallback runtime version: 2025.07** (Python 3.11). The current Colab default (Python 3.12+) is incompatible with `super-gradients`."
|
||||
]
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0
|
||||
}
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "rmuF9iKWTbdk"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"! pip install -q \"jedi>=0.16\"\n",
|
||||
"! pip install -q git+https://github.com/Deci-AI/super-gradients.git"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "NiRCt917KKcL"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": "! sed -i 's/sghub\\.deci\\.ai/d2gjn4b69gu75n.cloudfront.net/g; s/sg-hub-nv\\.s3\\.amazonaws\\.com/d2gjn4b69gu75n.cloudfront.net/g' /usr/local/lib/python*/dist-packages/super_gradients/training/pretrained_models.py\n! sed -i 's/sghub\\.deci\\.ai/d2gjn4b69gu75n.cloudfront.net/g; s/sg-hub-nv\\.s3\\.amazonaws\\.com/d2gjn4b69gu75n.cloudfront.net/g' /usr/local/lib/python*/dist-packages/super_gradients/training/utils/checkpoint_utils.py"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "dTB0jy_NNSFz"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from super_gradients.common.object_names import Models\n",
|
||||
"from super_gradients.conversion import DetectionOutputFormatMode\n",
|
||||
"from super_gradients.training import models\n",
|
||||
"\n",
|
||||
"model = models.get(Models.YOLO_NAS_S, pretrained_weights=\"coco\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "GymUghyCNXem"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# export the model for compatibility with Frigate\n",
|
||||
"\n",
|
||||
"model.export(\"yolo_nas_s.onnx\",\n",
|
||||
" output_predictions_format=DetectionOutputFormatMode.FLAT_FORMAT,\n",
|
||||
" max_predictions_per_image=20,\n",
|
||||
" num_pre_nms_predictions=300,\n",
|
||||
" confidence_threshold=0.4,\n",
|
||||
" input_image_shape=(320,320),\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "uBhXV5g4Nh42"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from google.colab import files\n",
|
||||
"\n",
|
||||
"files.download('yolo_nas_s.onnx')"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"colab": {
|
||||
"provenance": []
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0
|
||||
}
|
||||
@@ -213,6 +213,7 @@ export function AnimatedEventCard({
|
||||
playsInline
|
||||
muted
|
||||
disableRemotePlayback
|
||||
disablePictureInPicture
|
||||
loop
|
||||
onTimeUpdate={() => {
|
||||
if (!isLoaded) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { LiveStreamMetadata } from "@/types/live";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
const DEFER_DELAY_MS = 2000;
|
||||
const emptyObject: Readonly<{ [key: string]: LiveStreamMetadata }> =
|
||||
Object.freeze({});
|
||||
|
||||
/**
|
||||
* Hook that fetches go2rtc stream metadata with deferred loading.
|
||||
@@ -77,7 +79,7 @@ export default function useDeferredStreamMetadata(streamNames: string[]) {
|
||||
return metadata;
|
||||
}, []);
|
||||
|
||||
const { data: metadata = {} } = useSWR<{
|
||||
const { data: metadata = emptyObject } = useSWR<{
|
||||
[key: string]: LiveStreamMetadata;
|
||||
}>(swrKey, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,6 +162,10 @@ export type CameraConfigData = {
|
||||
input_args?: string;
|
||||
}[];
|
||||
};
|
||||
detect?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
live?: {
|
||||
streams: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -346,6 +346,7 @@ export interface FrigateConfig {
|
||||
};
|
||||
|
||||
auth: {
|
||||
enabled: boolean;
|
||||
roles: {
|
||||
[roleName: string]: string[];
|
||||
};
|
||||
|
||||
@@ -11,3 +11,7 @@ export type PreviewPlayback = {
|
||||
preview: Preview | undefined;
|
||||
timeRange: TimeRange;
|
||||
};
|
||||
|
||||
export type VodManifest = {
|
||||
sequences: { clips: { clipFrom?: number }[] }[];
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -44,6 +44,7 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import VideoControls from "@/components/player/VideoControls";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -918,25 +919,26 @@ function MotionReview({
|
||||
}: MotionReviewProps) {
|
||||
const segmentDuration = 30;
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const allowedCameras = useAllowedCameras();
|
||||
|
||||
const reviewCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let cameras;
|
||||
if (!filter || !filter.cameras) {
|
||||
cameras = Object.values(config.cameras);
|
||||
} else {
|
||||
const filteredCams = filter.cameras;
|
||||
|
||||
cameras = Object.values(config.cameras).filter((cam) =>
|
||||
filteredCams.includes(cam.name),
|
||||
);
|
||||
}
|
||||
const selectedCams = filter?.cameras;
|
||||
const cameras = Object.values(config.cameras).filter((cam) => {
|
||||
if (!allowedCameras.includes(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedCams && !selectedCams.includes(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return cameras.sort((a, b) => a.ui.order - b.ui.order);
|
||||
}, [config, filter]);
|
||||
}, [config, filter, allowedCameras]);
|
||||
|
||||
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user