Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 7f8443f9d4 Bump @remix-run/router and react-router-dom in /web
Bumps [@remix-run/router](https://github.com/remix-run/react-router/tree/HEAD/packages/router) to 1.23.3 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


Updates `@remix-run/router` from 1.23.2 to 1.23.3
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/@remix-run/router@1.23.3/packages/router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@remix-run/router@1.23.3/packages/router)

Updates `react-router-dom` from 6.30.3 to 6.30.4
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@6.30.4/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@6.30.4/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: "@remix-run/router"
  dependency-version: 1.23.3
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 6.30.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 12:03:27 +00:00
Josh HawkinsandGitHub e73a14db5d Miscellaneous fixes (0.18 beta) (#23898)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* update homekit docs

* update dictionary

* preserve function names in production builds

adds only 162kb gzipped/450k unzipped to the bundle

* margin tweak

* fix maximum update depth exceeded when dragging the timeline handlebar

Dragging the handlebar, especially quickly or with fast direction changes, could exceed React's nested update limit and unmount the whole app, leaving a blank screen. Motion search was worst affected.

The drag loop committed a new time into React state on every animation frame. Edge auto-scrolling mutates scrollTop each iteration, so the value always differed and React's same-value bail-out never engaged, letting the update chain run to the limit of 50. Pace those commits to one per 100ms and flush the pending value on release, so the drop position is still exact. The handlebar position and label are written to the DOM directly and remain at frame rate.

useUserInteraction dispatched state on every scroll and touchmove event; only commit on the leading edge.

Motion search also passed fresh array literals for the timeline's events, motion events and unavailable ranges, giving the segment memo and the drag effect new dependencies on every render. Both views also passed an inline arrow for onHandlebarDraggingChange, which is an effect dependency that calls setState.

* Verify motion search jobs belong to the requested camera

* Apply persisted profile and runtime overrides before workers start

Worker processes are handed a copy of the config when they start and only learn about later changes from the config_updater broadcast, which is plain ZMQ PUB/SUB with no queue, ack, or retained value, so a message published before a subscriber has connected is dropped and never re-sent. The persisted profile and the runtime camera toggles were restored only by that broadcast, at the very end of startup, so a worker that lost the race kept its yaml values for the rest of the session: audio detection kept running on a camera whose audio had been toggled off, even though /api/config, the UI, and the runtime state file all showed it disabled. Split both restores into a config half and a publish half. ProfileManager.restore_persisted_profile_to_config() and Dispatcher.reapply_runtime_state_to_config() now run right after init_profile_manager(), before the first worker starts, so every worker is handed a config that already carries both layers. ProfileManager.restore_persisted_profile() and Dispatcher.restore_runtime_state() still run at the end of startup: the recording, review, and embeddings processes start before the dispatcher exists, so the broadcast remains their only channel, and MQTT needs the retained switch states. Both config passes have to stay after init_profile_manager(), which snapshots the config as the no-profile base that deactivation resets to.

* End timeline drags on touchcancel
2026-08-05 07:40:24 -05:00
Josh HawkinsandGitHub 4883e20898 Pin the internal auth port to the value nginx bound at startup (#23909)
/auth grants anonymous admin to any request whose X-Server-Port matches networking.listen.internal, but it read that port off the live config while nginx binds its listeners once at container start and never reloads them, so any path that swaps the running config could move the trusted port without nginx moving with it. Saving networking.listen.internal equal to the external port applied immediately despite the restart-required warning, which handed unauthenticated admin to everything reaching the external port. Snapshot the port at app creation and compare against that instead, and reject a config whose two listeners share a port number, which nginx would refuse to start with anyway.
2026-08-05 07:39:56 -05:00
Josh HawkinsandGitHub 33c00a27e4 crop motion previews to the selected filter region (#23903)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
When a motion region filter is active, zoom each preview clip into the outer bounds of the selected cells instead of showing the full frame. Tiles take on the aspect ratio of the cropped region, clamped to avoid slivers when the selection is a single row or column, so the grid stays uniform. A "Crop to filter" switch in the preview settings turns this off and restores the previous 16:9 tiles. The transform is applied to a wrapper holding both the media and the dim overlay canvas so the motion heatmap stays registered to the pixels.

Fix the region filter grid, which mapped cells onto a hardcoded 16:9 box while the snapshot was letterboxed inside it with object-contain. Heatmap cells are indexed against the detect frame, so on a 4:3 camera every painted cell was off by up to 12.5% of the frame width, and the true left and right edges of the image could only be reached by painting the black bars. The grid box now takes the camera's detect aspect ratio, capped at 65dvh tall so 4:3 and portrait cameras do not overflow the dialog.
2026-08-04 08:07:06 -06:00
Josh HawkinsandGitHub 3b14ec0c87 Miscellaneous fixes (0.18 beta) (#23892)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* update network requirements docs for keras weights download

* fix manual PTZ relative moves permanently stopping object detection

* document available camera set features and link profiles docs to the API

* fix stale stream name field when switching cameras

The live streams and known plates fields rendered the map key as an uncontrolled input, so switching cameras left the previous camera's stream name on screen and would rename the wrong key if that stale text was committed. Both now use a shared MapKeyInput that resyncs with the form data and commits per keystroke, except while the typed name belongs to another entry, so the section is marked modified without waiting for blur.
2026-08-03 08:18:28 -05:00
Josh HawkinsandGitHub 4f2a297745 remove all references to degirum in frigate (#23882)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
the company ceased operations on 1 Aug 2026
2026-08-01 08:00:36 -06:00
Josh HawkinsandGitHub b848c90f02 Fix wrong box format passed to cv2.dnn.NMSBoxes (#23876)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-07-31 08:57:23 -05:00
Josh HawkinsandGitHub f1cc0e49d4 Miscellaneous fixes (0.18 beta) (#23873)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* improve display of gpu graphs in system metrics

* docs tweaks

* Only hide cameras with ui.dashboard disabled from the All Cameras dashboard

The settings camera selector and zone editor also filtered on ui.dashboard, so hiding a camera from the dashboard made its zones and masks uneditable in the UI (GH 23870). Drop those filters and correct the field title, help text, and reference docs to describe what the option actually does

* hide cameras with ui.review disabled from the Motion tab and the review summaries

The Motion tab built its own camera list that never checked ui.review, so a hidden camera still got a preview tile, and its motion and overlap queries fell back to every allowed camera. The review and recordings summaries had the same gap: they are aggregate day counts that can't be filtered client side, so a hidden camera kept contributing to the severity tab counts and calendar indicators while its items were absent from the list. Filter the motion camera list on ui.review and query all four endpoints with the visible camera list instead of letting the backend default to all, and skip the summary queries until the config resolves so the counts don't briefly render as zero.

* Scope every review page query to the cameras visible in review

The segments and the summary counts were derived from different camera sets: the list was fetched for all cameras and filtered client side, while the summaries were fetched for the visible cameras only when no explicit camera filter was set. A ?cameras= link can name a camera hidden from review, which left the count above zero with an empty list, pinning the new items to review popover open and making the auto refresh effect loop. Intersect an explicit camera selection with the visible list rather than trusting it, pass that to the segment and summary queries alike, and drop the now redundant client side filter, which the raw segments handed to the history view were bypassing anyway.
2026-07-30 17:20:41 -05:00
71 changed files with 2001 additions and 1217 deletions
+2
View File
@@ -8,6 +8,7 @@ amdgpu
analyzeduration
Annke
apexcharts
Aqara
arange
argmax
argmin
@@ -64,6 +65,7 @@ dsize
dtype
ECONNRESET
edgetpu
Eufy
facenet
fastapi
faststart
+1 -1
View File
@@ -1,7 +1,7 @@
default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.19.0
VERSION = 0.18.0
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
BOARDS= #Initialized empty
-2
View File
@@ -79,7 +79,5 @@ sherpa-onnx==1.12.*
faster-whisper==1.1.*
librosa==0.11.*
soundfile==0.13.*
# DeGirum detector
degirum == 0.16.*
# Memory profiling
memray == 1.15.*
-75
View File
@@ -1269,78 +1269,3 @@ axengine:
input_dtype: int
input_pixel_format: bgr
labelmap_path: /labelmap/coco-80.txt
degirumAiServer:
title: DeGirum AI Server
models:
- key: ai-server-inference
label: AI Server Inference
recommended: true
download: |-
Launch a DeGirum AI server as a Docker container, then point the detector at it. Add this to your `docker-compose.yml`:
```yaml
degirum_detector:
container_name: degirum
image: degirum/aiserver:latest
privileged: true
ports:
- "8778:8778"
```
Set `location` to the server's service name, container name, or `host:port`.
ui: |
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
| Field | Value |
| --- | --- |
| **Location** | `degirum` |
| **Zoo** | `degirum/public` |
| **Token** | your AI Hub token (optional for the public zoo) |
yaml: |
degirum_detector:
type: degirum
location: degirum
zoo: degirum/public
token: dg_example_token
degirumLocal:
title: DeGirum Local
models:
- key: local-inference
label: Local Inference
recommended: true
download: Run hardware directly inside the Frigate container with `@local`, removing the AI server hop. The matching device runtime (e.g. the Hailo runtime) must be installed in the container; confirm it with `degirum sys-info`.
ui: |
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
| Field | Value |
| --- | --- |
| **Location** | `@local` |
| **Zoo** | `degirum/public` |
| **Token** | your AI Hub token (optional for the public zoo) |
yaml: |
degirum_detector:
type: degirum
location: @local
zoo: degirum/public
token: dg_example_token
degirumCloud:
title: DeGirum AI Hub Cloud
models:
- key: ai-hub-cloud-inference
label: AI Hub Cloud Inference
recommended: true
download: Run inferences on DeGirum's [AI Hub](https://hub.degirum.com) cloud with `@cloud`. Sign up, create an access token, and set it as `token`. Network latency may require lowering your detection fps.
ui: |
Navigate to **Settings > System > Detectors and model** and select **DeGirum** from the detector type dropdown and click **Add**.
| Field | Value |
| --- | --- |
| **Location** | `@cloud` |
| **Zoo** | `degirum/public` |
| **Token** | your AI Hub token (optional for the public zoo) |
yaml: |
degirum_detector:
type: degirum
location: @cloud
zoo: degirum/public
token: dg_example_token
@@ -981,7 +981,9 @@ cameras:
# Optional: Adjust sort order of cameras in the UI. Larger numbers come later (default: shown below)
# By default the cameras are sorted alphabetically.
order: 0
# Optional: Whether or not to show the camera in the Frigate UI (default: shown below)
# Optional: Whether or not to show the camera on the default All Cameras live dashboard.
# The camera is still available everywhere else, including camera groups and settings
# (default: shown below)
dashboard: True
# Optional: Whether this camera is visible in review (the review page and its camera
# filter, motion review, and the history view) (default: shown below)
@@ -293,6 +293,10 @@ networking:
This setting is for advanced users. For the majority of use cases it's recommended to change the `ports` section of your Docker compose file or use the Docker `run` `--publish` option instead, e.g. `-p 443:8971`. Changing Frigate's ports may break some integrations.
The internal and external ports must be different port numbers, and Frigate will refuse to start otherwise. Requests arriving on the internal port are treated as authenticated admins, so pointing both at the same port would remove authentication from the external one.
Nginx binds these ports when it starts, so port changes only take effect after Frigate restarts.
:::
### Customizing the Nginx configuration
@@ -11,7 +11,7 @@ Object classification allows you to train a custom MobileNetV2 classification mo
:::info
Training a custom object classification model requires a one-time internet connection to download MobileNetV2 base weights. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
Training a custom object classification model requires an internet connection to download MobileNetV2 base weights. By default these weights are not cached in `/config/`, so they are downloaded again after the container is recreated. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
:::
@@ -11,7 +11,7 @@ State classification allows you to train a custom MobileNetV2 classification mod
:::info
Training a custom state classification model requires a one-time internet connection to download MobileNetV2 base weights. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
Training a custom state classification model requires an internet connection to download MobileNetV2 base weights. By default these weights are not cached in `/config/`, so they are downloaded again after the container is recreated. Once trained, the model runs fully offline. See [Network Requirements](/frigate/network_requirements#one-time-model-downloads) for details.
:::
+3 -1
View File
@@ -67,4 +67,6 @@ If your stream won't play, has no audio, uses excessive CPU, or otherwise misbeh
## Homekit Configuration
To add camera streams to Homekit Frigate must be configured in docker to use `host` networking mode. Once that is done, you can use the go2rtc WebUI (accessed via port 1984, which is disabled by default) to export a camera to Homekit. Any changes made will automatically be saved to `/config/go2rtc_homekit.yml`.
To export camera streams to HomeKit, Frigate must be configured in docker to use `host` networking mode. HomeKit settings are stored in `/config/go2rtc_homekit.yml` rather than in your Frigate config, and are edited through the go2rtc config editor at `http://<frigate_host>:1984/editor.html`. Pairings are saved back to that file automatically.
See the [HomeKit integration docs](/integrations/homekit) for the full setup, including the video and audio requirements HomeKit places on the stream.
+1 -1
View File
@@ -334,7 +334,7 @@ When your browser runs into problems playing back your camera streams, it will l
- **stalled**
- What it means: Playback has stalled because the player has fallen too far behind live (extended buffering or no data arriving).
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in the UI pane of Frigate's settings.
- What to try: This is usually indicative of the browser struggling to decode too many high-resolution streams at once. Try selecting a lower-bandwidth stream (substream), reduce the number of live streams open, improve the network connection, or lower the camera resolution. Also check your camera's keyframe (I-frame) interval: shorter intervals make playback start and recover faster. You can also try increasing the timeout value in <NavPath path="Settings > UI" /> .
- Possible console messages from the player code:
- `Buffer time (10 seconds) exceeded, browser may not be playing media correctly.`
@@ -24,7 +24,6 @@ Frigate supports multiple different detectors that work on different types of ha
- [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB, Mini PCIe, and m.2 formats allowing for a wide range of compatibility with devices.
- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices.
- <CommunityBadge /> [MemryX](#memryx-mx3): The MX3 Acceleration module is available in m.2 format, offering broad compatibility across various platforms.
- <CommunityBadge /> [DeGirum](#degirum): Service for using hardware devices in the cloud or locally. Hardware and models provided on the cloud on [their website](https://hub.degirum.com).
**AMD**
@@ -755,87 +754,6 @@ Explanation of the parameters:
- **example**: Specifying `output_name = "frigate-{quant}-{input_basename}-{soc}-v{tk_version}"` could result in a model called `frigate-i8-my_model-rk3588-v2.3.0.rknn`.
- `config`: Configuration passed to `rknn-toolkit2` for model conversion. For an explanation of all available parameters have a look at section "2.2. Model configuration" of [this manual](https://github.com/MarcA711/rknn-toolkit2/releases/download/v2.3.2/03_Rockchip_RKNPU_API_Reference_RKNN_Toolkit2_V2.3.2_EN.pdf).
## DeGirum
DeGirum is a detector that can use any type of hardware listed on [their website](https://hub.degirum.com). DeGirum can be used with local hardware through a DeGirum AI Server, or through the use of `@local`. You can also connect directly to DeGirum's AI Hub to run inferences. **Please Note:** This detector _cannot_ be used for commercial purposes.
### Configuration {#configuration-degirum}
#### AI Server Inference
Before starting with the config file for this section, you must first launch an AI server. DeGirum has an AI server ready to use as a docker container. Add this to your `docker-compose.yml` to get started:
```yaml
degirum_detector:
container_name: degirum
image: degirum/aiserver:latest
privileged: true
ports:
- "8778:8778"
```
All supported hardware will automatically be found on your AI server host as long as relevant runtimes and drivers are properly installed on your machine. Refer to [DeGirum's docs site](https://docs.degirum.com/pysdk/runtimes-and-drivers) if you have any trouble.
Once completed, configure the detector as follows:
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumAiServer.models} />
Setting up a model in the `config.yml` is similar to setting up an AI server.
You can set it to:
- A model listed on the [AI Hub](https://hub.degirum.com), given that the correct zoo name is listed in your detector
- If this is what you choose to do, the correct model will be downloaded onto your machine before running.
- A local directory acting as a zoo. See DeGirum's docs site [for more information](https://docs.degirum.com/pysdk/user-guide-pysdk/organizing-models#model-zoo-directory-structure).
- A path to some model.json.
```yaml
model:
path: ./mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1 # directory to model .json and file
width: 300 # width is in the model name as the first number in the "int"x"int" section
height: 300 # height is in the model name as the second number in the "int"x"int" section
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
```
#### Local Inference
It is also possible to eliminate the need for an AI server and run the hardware directly. The benefit of this approach is that you eliminate any bottlenecks that occur when transferring prediction results from the AI server docker container to the frigate one. However, the method of implementing local inference is different for every device and hardware combination, so it's usually more trouble than it's worth. A general guideline to achieve this would be:
1. Ensuring that the frigate docker container has the runtime you want to use. So for instance, running `@local` for Hailo means making sure the container you're using has the Hailo runtime installed.
2. To double check the runtime is detected by the DeGirum detector, make sure the `degirum sys-info` command properly shows whatever runtimes you mean to install.
3. Create a DeGirum detector in your configuration.
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumLocal.models} />
Once `degirum_detector` is setup, you can choose a model through 'model' section in the `config.yml` file.
```yaml
model:
path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
width: 300 # width is in the model name as the first number in the "int"x"int" section
height: 300 # height is in the model name as the second number in the "int"x"int" section
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
```
#### AI Hub Cloud Inference
If you do not possess whatever hardware you want to run, there's also the option to run cloud inferences. Do note that your detection fps might need to be lowered as network latency does significantly slow down this method of detection. For use with Frigate, we highly recommend using a local AI server as described above. To set up cloud inferences,
1. Sign up at [DeGirum's AI Hub](https://hub.degirum.com).
2. Get an access token.
3. Create a DeGirum detector in your configuration.
<ModelConfigDropdown detectorTitle="DeGirum" models={objectDetectorsModels.degirumCloud.models} />
Once `degirum_detector` is setup, you can choose a model through 'model' section in the `config.yml` file.
```yaml
model:
path: mobilenet_v2_ssd_coco--300x300_quant_n2x_orca1_1
width: 300 # width is in the model name as the first number in the "int"x"int" section
height: 300 # height is in the model name as the second number in the "int"x"int" section
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
```
## AXERA
Hardware accelerated object detection is supported on the following SoCs:
+1 -1
View File
@@ -126,7 +126,7 @@ Only the fields you explicitly set in a profile override are applied. All other
## Activating Profiles
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), or the Home Assistant integration.
Profiles can be activated and deactivated via the Frigate UI, [MQTT](/integrations/mqtt#frigateprofileset), the [HTTP API](../integrations/api/camera-set-camera-camera-name-set-feature-sub-command-put.api.mdx), or the Home Assistant integration.
In the Frigate UI, open the Settings cog and select **Profiles** from the submenu to see all defined profiles. From there you can activate any profile or deactivate the current one. The active profile is indicated in the UI so you always know which profile is in effect.
+25 -5
View File
@@ -34,6 +34,12 @@ The following models are downloaded automatically the first time their associate
| [Custom classification](/configuration/custom_classification/state_classification) (training) | MobileNetV2 ImageNet base weights (via Keras) | Google storage |
| [Audio transcription](/configuration/advanced/system) | Whisper or Sherpa-ONNX streaming model | HuggingFace / OpenAI |
:::note
The MobileNetV2 base weights are the one exception to the `/config/model_cache/` rule. They are also the only entry that is not downloaded when the feature is enabled: Frigate fetches them when a training run actually starts.
:::
### Hardware-Specific Detector Models
If you are using one of the following hardware detectors and have not provided your own model file, a default model will be downloaded on first startup:
@@ -75,7 +81,7 @@ If your Frigate instance has restricted internet access, you can point model dow
| `HF_ENDPOINT` | `https://huggingface.co` | Semantic search, Sherpa-ONNX, AXEngine models |
| `GITHUB_ENDPOINT` | `https://github.com` | Face recognition, LPR, RKNN models |
| `GITHUB_RAW_ENDPOINT` | `https://raw.githubusercontent.com` | Bird classification |
| `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` | Google storage (Keras default) | Custom classification training |
| `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` | Unset (Keras uses its own default) | Custom classification training |
## Optional Cloud Services
@@ -147,9 +153,23 @@ When running as a Home Assistant App, the go2rtc startup script queries the loca
To run Frigate in an air-gapped or offline environment:
1. **Pre-download models**: Start Frigate with internet access once with all desired features enabled. Models will be cached in `/config/model_cache/`.
2. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
3. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
4. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
5. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, and `GITHUB_RAW_ENDPOINT` environment variables to point to local mirrors.
2. **Pre-download the training base weights**: If you plan to train custom classification models, set `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` before training, then run one training job while online. Without this variable the base weights are cached outside `/config/` and are lost whenever the container is recreated, so a later training run will fail offline. If the machine never has internet access, copy the weights in manually as described below.
3. **Disable version check**: Set `telemetry.version_check: false` in your configuration.
4. **Block outbound model requests**: Set the `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1` environment variables to prevent HuggingFace and Transformers from attempting any network requests.
5. **Avoid cloud features**: Do not configure Frigate+, Generative AI providers that require internet, or cloud MQTT brokers.
6. **Use local model mirrors**: If limited internet is available, set the `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` environment variables to point to local mirrors.
After these steps, Frigate will operate with no outbound internet connections.
### Manually Copying the Training Base Weights
On a machine with internet access, download the weights:
```bash
curl -L -o mobilenet_v2_weights.h5 \
"https://storage.googleapis.com/tensorflow/keras-applications/mobilenet_v2/mobilenet_v2_weights_tf_dim_ordering_tf_kernels_0.35_224_no_top.h5"
```
Copy the file into your Frigate config volume as `/config/model_cache/MobileNet/mobilenet_v2_weights.h5`, keeping that exact filename, then set the environment variable `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` in your Docker compose file to the URL above and restart Frigate.
The variable must be set even though the URL is never contacted. If it is unset, Frigate ignores the copied file and asks Keras to download the weights instead.
+88 -23
View File
@@ -3,35 +3,100 @@ id: homekit
title: HomeKit
---
Frigate cameras can be integrated with Apple HomeKit through go2rtc. This allows you to view your camera streams directly in the Apple Home app on your iOS, iPadOS, macOS, and tvOS devices.
Frigate cameras can be exported to Apple HomeKit through go2rtc. Each exported camera appears as an accessory in the Apple Home app on your iOS, iPadOS, macOS, and tvOS devices.
## Overview
HomeKit integration is handled entirely through go2rtc, which is embedded in Frigate. go2rtc provides the necessary HomeKit Accessory Protocol (HAP) server to expose your cameras to HomeKit.
Exporting cameras is handled entirely through go2rtc, which is embedded in Frigate. go2rtc provides the necessary HomeKit Accessory Protocol (HAP) server, so your camera is published to HomeKit as an accessory in its own right.
## Setup
:::note
All HomeKit configuration and pairing should be done through the **go2rtc WebUI**.
This is the opposite of importing a HomeKit camera. go2rtc can also pair with an existing HomeKit camera (Aqara, Eve, Eufy, and similar) and use it as a stream source, which is what the `add` page of the go2rtc WebUI is for. That page discovers HomeKit accessories on your network and will not list your Frigate cameras. It is not used for exporting.
### Accessing the go2rtc WebUI
The go2rtc WebUI is available at:
```
http://<frigate_host>:1984
```
Replace `<frigate_host>` with the IP address or hostname of your Frigate server.
### Pairing Cameras
1. Navigate to the go2rtc WebUI at `http://<frigate_host>:1984`
2. Use the `add` section to add a new camera to HomeKit
3. Follow the on-screen instructions to generate pairing codes for your cameras
:::
## Requirements
- Frigate must be accessible on your local network using host network_mode
- Your iOS device must be on the same network as Frigate
- Port 1984 must be accessible for the go2rtc WebUI
- For detailed go2rtc configuration options, refer to the [go2rtc documentation](https://github.com/AlexxIT/go2rtc)
- Frigate must be running with `network_mode: host` so that HomeKit can discover your cameras over mDNS
- Your Apple device must be on the same network as Frigate
- Port 1984 must be accessible so you can reach the go2rtc WebUI
HomeKit also places strict limits on the stream itself. go2rtc passes your stream through without resizing or re-encoding it, so the stream you export must already meet these requirements:
- **Video:** H.264 at 1920x1080, 1280x720, or 320x240
- **Audio:** Opus, mono, 16 kHz
A camera's full resolution stream usually does not qualify. See [Exporting a compatible stream](#exporting-a-compatible-stream) below.
## Configuration
HomeKit settings are stored in `/config/go2rtc_homekit.yml`. This is a separate file from your Frigate config, because go2rtc needs to write your pairings back to it when you pair a device.
Edit it using the go2rtc config editor, which writes to that file directly:
```
http://<frigate_host>:1984/editor.html
```
Replace `<frigate_host>` with the IP address or hostname of your Frigate server. The editor will be empty until you add a HomeKit section, since this file holds only your HomeKit settings and not the rest of your go2rtc config.
:::warning
Do not put the `homekit:` section in the `go2rtc:` section of your Frigate config.
Frigate regenerates that config on every startup, so go2rtc cannot save your pairings to it. Pairing will appear to succeed and then fail after the next restart with `PairVerify with unknown client_id`. If the section exists in both places, your saved pairings are erased on every restart.
:::
Add an entry for each camera you want to export. The key must match the name of a go2rtc stream, and the pin must be 8 digits. This is the number the Home app calls the setup code:
```yaml
homekit:
front_door:
name: Front Door
pin: "12345678"
```
If the key does not match a go2rtc stream, go2rtc logs `[homekit] missing stream:` at startup and the camera will not appear in the Home app.
:::note
go2rtc derives each accessory's HomeKit identity from this key, so renaming it later means the camera appears as a new accessory and has to be paired again. Settle on the name before you pair.
:::
Frigate keeps only the `homekit:` section of this file when it starts, so do not store streams or other go2rtc settings in it.
### Exporting a compatible stream
If a camera's stream does not meet the requirements listed above, define a scaled restream in your Frigate config and point HomeKit at that stream instead of the original:
```yaml
go2rtc:
streams:
front_door:
- rtsp://user:password@192.168.1.50:554/stream
front_door_homekit:
- "ffmpeg:front_door#video=h264#width=1280#height=720#audio=opus/16000"
```
```yaml
# /config/go2rtc_homekit.yml
homekit:
front_door_homekit:
name: Front Door
pin: "12345678"
```
Add `#hardware=cuda`, `#hardware=vaapi`, or the appropriate value for your system to transcode using your GPU. Note that NVENC cannot encode H.264 wider than 4096 pixels, so very wide streams must be scaled down as shown above rather than only re-encoded.
## Pairing Cameras
1. Restart Frigate after adding the `homekit:` section
2. In the Apple Home app, choose **Add Accessory**, then **More options** to enter a code manually
3. Select your camera and enter the pin you configured as the setup code
4. Confirm that a `pairings:` list now appears under the camera in `/config/go2rtc_homekit.yml`
Pairings are saved back to that file automatically. If step 4 shows no `pairings:` list, check the Frigate log for `[homekit] can't save`, which means the `homekit:` section is missing from `/config/go2rtc_homekit.yml`.
For detailed go2rtc configuration options, refer to the [go2rtc documentation](https://github.com/AlexxIT/go2rtc).
+1 -1
View File
@@ -39,7 +39,7 @@ The per-clip variation is typically quite low and is mostly an artifact of keyfr
Debug Replay lets you re-run Frigate's detection pipeline against a section of recorded video without manually configuring a dummy camera. It automatically extracts the recording, creates a temporary camera with the same detection settings as the original, and loops the clip through the pipeline so you can observe detections in real time.
The replay camera behaves like a live camera feed rather than History's video player: it loops the clip continuously as Frigate analyzes it and has no playback controls, so you cannot pause, scrub, or step through it frame by frame.
The replay camera behaves like a live camera feed rather than History's video player: it loops the clip continuously as Frigate analyzes it and has no playback controls, so you cannot pause, scrub, or step through it frame by frame. The Debug Replay camera does not save recordings or snapshots or surface anything in Explore, but it otherwise behaves like a regular camera, including running enrichments such as Face Recognition, LPR, and custom classification.
Debug Replay isn't intended to be a one-stop pane for all Frigate diagnostics or a comprehensive debugging environment for every Frigate feature. It merely makes it easier to spin up a "dummy camera" and perform some common adjustments in real time. You'll still need to use the normal tools (logs, an MQTT client, etc) to debug your feature.
+74 -38
View File
@@ -693,6 +693,43 @@ paths:
**Access:** Admin role required.
Set a camera feature state. Use camera_name='*' to target all cameras.
The value to set is sent in the request body as `{"value": "<value>"}`.
| Feature | Accepted values |
| --- | --- |
| `enabled` | `ON`, `OFF` |
| `detect` | `ON`, `OFF` |
| `motion` | `ON`, `OFF` |
| `recordings` | `ON`, `OFF` |
| `snapshots` | `ON`, `OFF` |
| `audio` | `ON`, `OFF` |
| `audio_transcription` | `ON`, `OFF` |
| `notifications` | `ON`, `OFF` |
| `review_alerts` | `ON`, `OFF` |
| `review_detections` | `ON`, `OFF` |
| `object_descriptions` | `ON`, `OFF` |
| `review_descriptions` | `ON`, `OFF` |
| `improve_contrast` | `ON`, `OFF` |
| `ptz_autotracker` | `ON`, `OFF` |
| `birdseye` | `ON`, `OFF` |
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
| `motion_contour_area` | integer |
| `motion_threshold` | integer |
| `motion_mask` | `ON`, `OFF` |
| `object_mask` | `ON`, `OFF` |
| `zone` | `ON`, `OFF` |
| `profile` | a profile name, or `none` to deactivate |
`motion_mask`, `object_mask`, and `zone` require the `sub_command` path
parameter to be set to the name of the mask or zone. All other features
reject a sub-command.
`profile` applies globally rather than per camera, so it requires
`camera_name` to be `*`.
These features map to the equivalent MQTT topics, which document the
behavior of each value in more detail.
operationId:
camera_set_camera__camera_name__set__feature___sub_command__put
parameters:
@@ -746,6 +783,43 @@ paths:
**Access:** Admin role required.
Set a camera feature state. Use camera_name='*' to target all cameras.
The value to set is sent in the request body as `{"value": "<value>"}`.
| Feature | Accepted values |
| --- | --- |
| `enabled` | `ON`, `OFF` |
| `detect` | `ON`, `OFF` |
| `motion` | `ON`, `OFF` |
| `recordings` | `ON`, `OFF` |
| `snapshots` | `ON`, `OFF` |
| `audio` | `ON`, `OFF` |
| `audio_transcription` | `ON`, `OFF` |
| `notifications` | `ON`, `OFF` |
| `review_alerts` | `ON`, `OFF` |
| `review_detections` | `ON`, `OFF` |
| `object_descriptions` | `ON`, `OFF` |
| `review_descriptions` | `ON`, `OFF` |
| `improve_contrast` | `ON`, `OFF` |
| `ptz_autotracker` | `ON`, `OFF` |
| `birdseye` | `ON`, `OFF` |
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
| `motion_contour_area` | integer |
| `motion_threshold` | integer |
| `motion_mask` | `ON`, `OFF` |
| `object_mask` | `ON`, `OFF` |
| `zone` | `ON`, `OFF` |
| `profile` | a profile name, or `none` to deactivate |
`motion_mask`, `object_mask`, and `zone` require the `sub_command` path
parameter to be set to the name of the mask or zone. All other features
reject a sub-command.
`profile` applies globally rather than per camera, so it requires
`camera_name` to be `*`.
These features map to the equivalent MQTT topics, which document the
behavior of each value in more detail.
operationId: camera_set_camera__camera_name__set__feature__put
parameters:
- name: camera_name
@@ -2872,44 +2946,6 @@ paths:
- frigateUserAuth: []
x-required-role: any
description: '**Access:** Any authenticated user.'
/categorized_object_names:
get:
tags:
- App
summary: Get known object names by object type
description: |-
**Access:** Any authenticated user.
Returns the sub labels and attributes this install can attach,
grouped by object type. Unlike /sub_labels, which reflects what has already been
detected, this reads the config and model files, so it covers recognized face
names, named license plates, custom object classification categories, and the
detector attributes of tracked objects.
operationId: categorized_object_names_categorized_object_names_get
parameters:
- name: object_type
in: query
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: Object Type
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateUserAuth: []
x-required-role: any
/audio_labels:
get:
tags:
-23
View File
@@ -71,7 +71,6 @@ from frigate.util.config import (
find_config_file,
redact_credential,
)
from frigate.util.object_names import get_categorized_object_names
from frigate.util.schema import get_config_schema
from frigate.util.services import (
get_nvidia_driver_info,
@@ -1314,28 +1313,6 @@ def get_sub_labels(
return JSONResponse(content=sub_labels)
@router.get(
"/categorized_object_names",
dependencies=[Depends(allow_any_authenticated())],
summary="Get known object names by object type",
description="""Returns the sub labels and attributes this install can attach,
grouped by object type. Unlike /sub_labels, which reflects what has already been
detected, this reads the config and model files, so it covers recognized face
names, named license plates, custom object classification categories, and the
detector attributes of tracked objects.""",
)
def categorized_object_names(
request: Request,
object_type: str | None = None,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
):
return JSONResponse(
content=get_categorized_object_names(
request.app.frigate_config, allowed_cameras, object_type
)
)
@router.get("/audio_labels", dependencies=[Depends(allow_any_authenticated())])
def get_audio_labels():
labels = load_labels("/audio-labelmap.txt", prefill=521)
+9 -10
View File
@@ -31,7 +31,7 @@ from frigate.api.media_auth import (
deny_response_for_media_uri,
is_role_restricted,
)
from frigate.config import AuthConfig, NetworkingConfig, ProxyConfig
from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User
@@ -83,7 +83,6 @@ def require_admin_by_default():
"/nvinfo",
"/labels",
"/sub_labels",
"/categorized_object_names",
"/plus/models",
"/recognized_license_plates",
"/timeline",
@@ -621,18 +620,18 @@ def resolve_role(
def auth(request: Request):
auth_config: AuthConfig = request.app.frigate_config.auth
proxy_config: ProxyConfig = request.app.frigate_config.proxy
networking_config: NetworkingConfig = request.app.frigate_config.networking
success_response = Response("", status_code=202)
# handle case where internal port is a string with ip:port
internal_port = networking_config.listen.internal
if type(internal_port) is str:
internal_port = int(internal_port.split(":")[-1])
# dont require auth if the request is on the internal port
# this header is set by Frigate's nginx proxy, so it cant be spoofed
if int(request.headers.get("x-server-port", default=0)) == internal_port:
# this header is set by Frigate's nginx proxy, so it cant be spoofed.
# the port is the boot-time snapshot rather than the live config value:
# nginx's listeners are fixed at container start, so an in-memory config
# change must never move the port that is trusted here
if (
int(request.headers.get("x-server-port", default=0))
== request.app.auth_internal_port
):
success_response.headers["remote-user"] = "anonymous"
success_response.headers["remote-role"] = "admin"
return success_response
+39 -1
View File
@@ -1328,7 +1328,45 @@ def camera_set(
body: CameraSetBody,
sub_command: str | None = None,
):
"""Set a camera feature state. Use camera_name='*' to target all cameras."""
"""Set a camera feature state. Use camera_name='*' to target all cameras.
The value to set is sent in the request body as `{"value": "<value>"}`.
| Feature | Accepted values |
| --- | --- |
| `enabled` | `ON`, `OFF` |
| `detect` | `ON`, `OFF` |
| `motion` | `ON`, `OFF` |
| `recordings` | `ON`, `OFF` |
| `snapshots` | `ON`, `OFF` |
| `audio` | `ON`, `OFF` |
| `audio_transcription` | `ON`, `OFF` |
| `notifications` | `ON`, `OFF` |
| `review_alerts` | `ON`, `OFF` |
| `review_detections` | `ON`, `OFF` |
| `object_descriptions` | `ON`, `OFF` |
| `review_descriptions` | `ON`, `OFF` |
| `improve_contrast` | `ON`, `OFF` |
| `ptz_autotracker` | `ON`, `OFF` |
| `birdseye` | `ON`, `OFF` |
| `birdseye_mode` | `CONTINUOUS`, `MOTION`, `OBJECTS` |
| `motion_contour_area` | integer |
| `motion_threshold` | integer |
| `motion_mask` | `ON`, `OFF` |
| `object_mask` | `ON`, `OFF` |
| `zone` | `ON`, `OFF` |
| `profile` | a profile name, or `none` to deactivate |
`motion_mask`, `object_mask`, and `zone` require the `sub_command` path
parameter to be set to the name of the mask or zone. All other features
reject a sub-command.
`profile` applies globally rather than per camera, so it requires
`camera_name` to be `*`.
These features map to the equivalent MQTT topics, which document the
behavior of each value in more detail.
"""
dispatcher = request.app.dispatcher
frigate_config: FrigateConfig = request.app.frigate_config
+2 -25
View File
@@ -50,7 +50,6 @@ from frigate.jobs.vlm_watch import (
stop_vlm_watch_job,
)
from frigate.models import Event
from frigate.util.object_names import get_categorized_object_names
logger = logging.getLogger(__name__)
@@ -540,11 +539,6 @@ async def execute_tool(
if tool_name == "search_objects":
return await _execute_search_objects(request, arguments, allowed_cameras)
if tool_name == "get_categorized_object_names":
return JSONResponse(
content=_execute_get_categorized_object_names(request, allowed_cameras)
)
if tool_name == "find_similar_objects":
result = await _execute_find_similar_objects(
request, arguments, allowed_cameras
@@ -723,21 +717,6 @@ async def _execute_set_camera_state(
return {"success": True, "camera": camera, "feature": feature, "value": value}
def _execute_get_categorized_object_names(
request: Request,
allowed_cameras: list[str],
) -> dict[str, Any]:
names = get_categorized_object_names(request.app.frigate_config, allowed_cameras)
if not names:
return {
"names": {},
"message": "No names configured; search by label or semantic_query.",
}
return {"names": names}
async def _execute_tool_internal(
tool_name: str,
arguments: dict[str, Any],
@@ -762,8 +741,6 @@ async def _execute_tool_internal(
except (json.JSONDecodeError, AttributeError) as e:
logger.warning(f"Failed to extract tool result: {e}")
return {"error": "Failed to parse tool result"}
elif tool_name == "get_categorized_object_names":
return _execute_get_categorized_object_names(request, allowed_cameras)
elif tool_name == "find_similar_objects":
return await _execute_find_similar_objects(request, arguments, allowed_cameras)
elif tool_name == "set_camera_state":
@@ -796,8 +773,8 @@ async def _execute_tool_internal(
else:
logger.error(
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
"get_categorized_object_names, get_live_context, start_camera_watch, stop_camera_watch, "
"get_profile_status, get_recap. Arguments received: %s",
"get_live_context, start_camera_watch, stop_camera_watch, get_profile_status, get_recap. "
"Arguments received: %s",
tool_name,
json.dumps(arguments),
)
+2
View File
@@ -152,6 +152,8 @@ def create_fastapi_app(
app.include_router(debug_replay.router)
# App Properties
app.frigate_config = frigate_config
# snapshot the port nginx bound at startup, the live config can be swapped
app.auth_internal_port = frigate_config.networking.listen.internal_port
app.genai_manager = GenAIClientManager(frigate_config)
app.embeddings = embeddings
app.detected_frames_processor = detected_frames_processor
+4 -4
View File
@@ -574,11 +574,11 @@ async def vod_ts(
Recordings.start_time,
)
.where(
Recordings.camera == camera_name,
Recordings.start_time >= start_ts - MAX_SEGMENT_DURATION,
Recordings.start_time <= end_ts,
Recordings.end_time >= start_ts,
Recordings.start_time.between(start_ts, end_ts)
| Recordings.end_time.between(start_ts, end_ts)
| ((start_ts > Recordings.start_time) & (end_ts < Recordings.end_time))
)
.where(Recordings.camera == camera_name)
.order_by(Recordings.start_time.asc())
.iterator()
)
+2 -2
View File
@@ -182,7 +182,7 @@ async def get_motion_search_status_endpoint(
)
job = get_motion_search_job(job_id)
if not job:
if not job or job.camera != camera_name:
return JSONResponse(
content={"success": False, "message": "Job not found"},
status_code=404,
@@ -253,7 +253,7 @@ async def cancel_motion_search_endpoint(
)
job = get_motion_search_job(job_id)
if not job:
if not job or job.camera != camera_name:
return JSONResponse(
content={"success": False, "message": "Job not found"},
status_code=404,
+1 -2
View File
@@ -25,7 +25,7 @@ from frigate.api.defs.query.recordings_query_parameters import (
)
from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags
from frigate.const import MAX_SEGMENT_DURATION, RECORD_DIR
from frigate.const import RECORD_DIR
from frigate.models import Event, Recordings
from frigate.util.time import get_dst_transitions
@@ -243,7 +243,6 @@ async def recordings(
)
.where(
Recordings.camera == camera_name,
Recordings.start_time >= after - MAX_SEGMENT_DURATION,
Recordings.end_time >= after,
Recordings.start_time <= before,
)
+10 -22
View File
@@ -122,7 +122,6 @@ class FrigateApp:
self.ptz_metrics: dict[str, PTZMetrics] = {}
self.processes: dict[str, int] = {}
self.embeddings: EmbeddingsContext | None = None
self.profile_manager: ProfileManager | None = None
self.config_holder = ConfigHolder(config)
@property
@@ -355,25 +354,6 @@ class FrigateApp:
)
self.dispatcher.profile_manager = self.profile_manager
def restore_active_profile(self) -> None:
"""Re-activate the persisted profile after subscribers are connected.
ZMQ PUB/SUB drops messages with no subscribers, so activation must
run after every config_updater subscriber is up.
"""
if self.profile_manager is None:
return
persisted = ProfileManager.load_persisted_profile()
if persisted and any(
persisted in cam.profiles for cam in self.config.cameras.values()
):
logger.info("Restoring persisted profile '%s'", persisted)
# runtime overrides are layered on top via restore_runtime_state()
self.profile_manager.activate_profile(
persisted, clear_runtime_overrides=False
)
def start_detectors(self) -> None:
for name in self.config.cameras.keys():
try:
@@ -622,6 +602,13 @@ class FrigateApp:
self.start_detectors()
self.init_dispatcher()
self.init_profile_manager()
# workers get a copy of the config and can miss the broadcast below, so
# apply both layers here. must stay after init_profile_manager(), which
# snapshots the base config that profile deactivation resets to
self.profile_manager.restore_persisted_profile_to_config()
self.dispatcher.reapply_runtime_state_to_config()
self.init_embeddings_client()
self.start_video_output_processor()
self.start_ptz_autotracker()
@@ -636,8 +623,9 @@ class FrigateApp:
self.start_record_cleanup()
self.start_watchdog()
# restore persisted runtime overrides on top of config
self.restore_active_profile()
# publish for the recording/review/embeddings processes, which start
# before the config can be corrected, and for the retained MQTT states
self.profile_manager.restore_persisted_profile()
self.dispatcher.restore_runtime_state()
self.init_auth()
+2 -2
View File
@@ -13,8 +13,8 @@ class CameraUiConfig(FrigateBaseModel):
)
dashboard: bool = Field(
default=True,
title="Show in UI",
description="Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.",
title="Show on Live dashboard",
description="Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings.",
)
review: bool = Field(
default=True,
+24 -1
View File
@@ -1,10 +1,18 @@
from pydantic import Field
from pydantic import Field, model_validator
from .base import FrigateBaseModel
__all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"]
def parse_listen_port(value: int | str) -> int:
"""Return the port number from a bare port or an "address:port" value."""
if isinstance(value, str):
return int(value.split(":")[-1])
return value
class IPv6Config(FrigateBaseModel):
enabled: bool = Field(
default=False,
@@ -25,6 +33,21 @@ class ListenConfig(FrigateBaseModel):
description="External listening port for Frigate (default 8971).",
)
@property
def internal_port(self) -> int:
return parse_listen_port(self.internal)
@property
def external_port(self) -> int:
return parse_listen_port(self.external)
@model_validator(mode="after")
def validate_distinct_ports(self) -> "ListenConfig":
if self.internal_port == self.external_port:
raise ValueError("internal and external must listen on different ports")
return self
class NetworkingConfig(FrigateBaseModel):
ipv6: IPv6Config = Field(
+94 -14
View File
@@ -169,6 +169,93 @@ class ProfileManager:
self.config.active_profile = None
self._persist_active_profile(None)
def _validate_profile_name(self, profile_name: str | None) -> str | None:
"""Return an error message if the name is not a defined profile."""
if profile_name is not None and profile_name not in self.config.profiles:
return f"Profile '{profile_name}' is not defined in the profiles section"
return None
def _apply_to_config(
self, profile_name: str | None
) -> tuple[dict[str, set[str]], str | None]:
"""Reset every camera to base, then apply the named profile on top.
Returns the changed camera/section pairs, plus an error message if
applying the profile failed partway through.
"""
changed: dict[str, set[str]] = {}
self._reset_to_base(changed)
if profile_name is not None:
err = self._apply_profile_overrides(profile_name, changed)
if err:
return changed, err
return changed, None
def apply_profile_to_config(self, profile_name: str | None) -> str | None:
"""Apply a profile to the in-memory config, without publishing it.
Safe to call ahead of activate_profile: both reset to the base config
first, so the later call re-derives the same state and still reports
every section as changed.
Returns:
None on success, or an error message string on failure.
"""
err = self._validate_profile_name(profile_name)
if err:
return err
return self._apply_to_config(profile_name)[1]
def _persisted_profile_to_restore(self) -> str | None:
"""Return the persisted profile name, if it still applies to a camera."""
persisted = self.load_persisted_profile()
if not persisted or not any(
persisted in cam.profiles for cam in self.config.cameras.values()
):
return None
return persisted
def restore_persisted_profile_to_config(self) -> None:
"""Restore the persisted profile into the config, without publishing.
Called before worker processes start, so they are handed a config that
already carries the profile rather than relying on the broadcast that
restore_persisted_profile() sends later.
"""
persisted = self._persisted_profile_to_restore()
if persisted is None:
return
err = self.apply_profile_to_config(persisted)
if err:
logger.error("Failed to apply persisted profile '%s': %s", persisted, err)
def restore_persisted_profile(self) -> None:
"""Re-activate the persisted profile once subscribers are connected.
The config already carries the profile; this pass publishes it for the
processes that start before the config can be corrected, and for the
retained MQTT states.
"""
persisted = self._persisted_profile_to_restore()
if persisted is None:
return
logger.info("Restoring persisted profile '%s'", persisted)
# runtime overrides are layered on top by the dispatcher's replay
self.activate_profile(persisted, clear_runtime_overrides=False)
def activate_profile(
self,
profile_name: str | None,
@@ -187,23 +274,16 @@ class ProfileManager:
Returns:
None on success, or an error message string on failure.
"""
if profile_name is not None:
if profile_name not in self.config.profiles:
return (
f"Profile '{profile_name}' is not defined in the profiles section"
)
err = self._validate_profile_name(profile_name)
if err:
return err
# Track which camera/section pairs get changed for ZMQ publishing
changed: dict[str, set[str]] = {}
changed, err = self._apply_to_config(profile_name)
# Reset all cameras to base config
self._reset_to_base(changed)
# Apply new profile overrides if activating
if profile_name is not None:
err = self._apply_profile_overrides(profile_name, changed)
if err:
return err
if err:
return err
# Publish ZMQ updates only for sections that actually changed
self._publish_updates(changed)
-157
View File
@@ -1,157 +0,0 @@
import logging
import queue
from typing import Literal
import numpy as np
from pydantic import ConfigDict, Field
from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig
logger = logging.getLogger(__name__)
DETECTOR_KEY = "degirum"
### DETECTOR CONFIG ###
class DGDetectorConfig(BaseDetectorConfig):
"""DeGirum detector for running models via DeGirum cloud or local inference services."""
model_config = ConfigDict(
title="DeGirum",
)
type: Literal[DETECTOR_KEY]
location: str = Field(
default=None,
title="Inference Location",
description="Location of the DeGirim inference engine (e.g. '@cloud', '127.0.0.1').",
)
zoo: str = Field(
default=None,
title="Model Zoo",
description="Path or URL to the DeGirum model zoo.",
)
token: str = Field(
default=None,
title="DeGirum Cloud Token",
description="Token for DeGirum Cloud access.",
)
### ACTUAL DETECTOR ###
class DGDetector(DetectionApi):
type_key = DETECTOR_KEY
def __init__(self, detector_config: DGDetectorConfig):
try:
import degirum as dg
except ModuleNotFoundError:
raise ImportError("Unable to import DeGirum detector.") from None
self._queue = queue.Queue()
self._zoo = dg.connect(
detector_config.location, detector_config.zoo, detector_config.token
)
logger.debug(f"Models in zoo: {self._zoo.list_models()}")
self.dg_model = self._zoo.load_model(
detector_config.model.path,
)
# Setting input image format to raw reduces preprocessing time
self.dg_model.input_image_format = "RAW"
# Prioritize the most powerful hardware available
self.select_best_device_type()
# Frigate handles pre processing as long as these are all set
input_shape = self.dg_model.input_shape[0]
self.model_height = input_shape[1]
self.model_width = input_shape[2]
# Passing in dummy frame so initial connection latency happens in
# init function and not during actual prediction
frame = np.zeros(
(detector_config.model.width, detector_config.model.height, 3),
dtype=np.uint8,
)
# Pass in frame to overcome first frame latency
self.dg_model(frame)
self.prediction = self.prediction_generator()
def select_best_device_type(self):
"""
Helper function that selects fastest hardware available per model runtime
"""
types = self.dg_model.supported_device_types
device_map = {
"OPENVINO": ["GPU", "NPU", "CPU"],
"HAILORT": ["HAILO8L", "HAILO8"],
"N2X": ["ORCA1", "CPU"],
"ONNX": ["VITIS_NPU", "CPU"],
"RKNN": ["RK3566", "RK3568", "RK3588"],
"TENSORRT": ["DLA", "GPU", "DLA_ONLY"],
"TFLITE": ["ARMNN", "EDGETPU", "CPU"],
}
runtime = types[0].split("/")[0]
# Just create an array of format {runtime}/{hardware} for every hardware
# in the value for appropriate key in device_map
self.dg_model.device_type = [
f"{runtime}/{hardware}" for hardware in device_map[runtime]
]
def prediction_generator(self):
"""
Generator for all incoming frames. By using this generator, we don't have to keep
reconnecting our websocket on every "predict" call.
"""
logger.debug("Prediction generator was called")
with self.dg_model as model:
while 1:
logger.info(f"q size before calling get: {self._queue.qsize()}")
data = self._queue.get(block=True)
logger.info(f"q size after calling get: {self._queue.qsize()}")
logger.debug(
f"Data we're passing into model predict: {data}, shape of data: {data.shape}"
)
result = model.predict(data)
logger.debug(f"Prediction result: {result}")
yield result
def detect_raw(self, tensor_input):
# Reshaping tensor to work with pysdk
truncated_input = tensor_input.reshape(tensor_input.shape[1:])
logger.debug(f"Detect raw was called for tensor input: {tensor_input}")
# add tensor_input to input queue
self._queue.put(truncated_input)
logger.debug(f"Queue size after adding truncated input: {self._queue.qsize()}")
# define empty detection result
detections = np.zeros((20, 6), np.float32)
# grab prediction
res = next(self.prediction)
# If we have an empty prediction, return immediately
if len(res.results) == 0 or len(res.results[0]) == 0:
return detections
i = 0
for result in res.results:
if i >= 20:
break
detections[i] = [
result["category_id"],
float(result["score"]),
result["bbox"][1] / self.model_height,
result["bbox"][0] / self.model_width,
result["bbox"][3] / self.model_height,
result["bbox"][2] / self.model_width,
]
i += 1
logger.debug(f"Detections output: {detections}")
return detections
+2 -1
View File
@@ -9,6 +9,7 @@ from pydantic import ConfigDict, Field
from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
from frigate.util.model import xyxy_to_xywh_for_nms
try:
from tflite_runtime.interpreter import Interpreter, load_delegate
@@ -297,7 +298,7 @@ class EdgeTpuTfl(DetectionApi):
# until after filtering out redundant boxes
# Shift the logit scores to be non-negative (required by cv2)
indices = cv2.dnn.NMSBoxes(
bboxes=boxes_filtered_decoded,
bboxes=xyxy_to_xywh_for_nms(boxes_filtered_decoded),
scores=max_scores_filtered_shiftedpositive,
score_threshold=(
self.min_logit_value + self.logit_shift_to_positive_values
+3 -2
View File
@@ -17,6 +17,7 @@ from frigate.detectors.detector_config import (
ModelTypeEnum,
)
from frigate.util.file import FileLock
from frigate.util.model import xyxy_to_xywh_for_nms
logger = logging.getLogger(__name__)
@@ -581,7 +582,7 @@ class MemryXDetector(DetectionApi):
# Convert coordinates to integers
x_min, y_min, x_max, y_max = map(int, [x_min, y_min, x_max, y_max])
# Append valid detections [class_id, confidence, x, y, width, height]
# Append valid detections [class_id, confidence, x_min, y_min, x_max, y_max]
detections.append([class_id, confidence, x_min, y_min, x_max, y_max])
final_detections = np.zeros((20, 6), np.float32)
@@ -595,7 +596,7 @@ class MemryXDetector(DetectionApi):
detections = np.array(detections, dtype=np.float32)
# Apply Non-Maximum Suppression (NMS)
bboxes = detections[:, 2:6].tolist() # (x_min, y_min, width, height)
bboxes = xyxy_to_xywh_for_nms(detections[:, 2:6])
scores = detections[:, 1].tolist() # Confidence scores
indices = cv2.dnn.NMSBoxes(bboxes, scores, 0.45, 0.5)
+2 -2
View File
@@ -12,7 +12,7 @@ from frigate.const import MODEL_CACHE_DIR, SUPPORTED_RK_SOCS
from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detection_runners import RKNNModelRunner
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
from frigate.util.model import post_process_yolo
from frigate.util.model import post_process_yolo, xyxy_to_xywh_for_nms
from frigate.util.rknn_converter import auto_convert_model
logger = logging.getLogger(__name__)
@@ -285,7 +285,7 @@ class Rknn(DetectionApi):
# run nms
indices = cv2.dnn.NMSBoxes(
bboxes=boxes,
bboxes=xyxy_to_xywh_for_nms(boxes),
scores=scores,
score_threshold=0.4,
nms_threshold=0.4,
+120 -64
View File
@@ -262,10 +262,6 @@ def get_tool_definitions(
`attribute` parameter is exposed for filtering by their labels. When the
embeddings model only understands English (JinaV1), the `semantic_query`
description instructs the model to write the query in English.
Descriptions here stay mechanical: which tool to reach for, and how the
filters relate to each other, is stated once in the system prompt so the
guidance is not paid for twice on every request.
"""
search_objects_properties: dict[str, Any] = {
"camera": {
@@ -274,13 +270,26 @@ def get_tool_definitions(
},
"label": {
"type": "string",
"description": "Tracked object class to filter by.",
"description": (
"Generic object class to filter by — one of the tracked detector "
"labels such as 'person', 'package', 'car', 'dog', 'bird'. Use "
"this for broad queries like 'show me all cars today'. Combine "
"with semantic_query when the user also describes appearance or "
"behavior (e.g. label='person', semantic_query='riding a lawn "
"mower')."
),
},
"sub_label": {
"type": "string",
"description": (
"Name recognized in the detection: a person, delivery company, "
"animal species or breed, or license plate."
"Filter by a DISCRETE NAMED entity recognized in the detection. "
"Use this for: a known person's name ('John'), a delivery "
"company ('Amazon', 'UPS'), a recognized animal species or "
"breed ('blue jay', 'cardinal', 'golden retriever'), or a "
"license plate string. When filtering by a specific name, set "
"only sub_label and leave label unset. Do NOT use sub_label "
"for descriptions of appearance, clothing, or actions — those "
"belong in semantic_query."
),
},
"after": {
@@ -304,11 +313,20 @@ def get_tool_definitions(
}
if attribute_classifications:
model_outline = "; ".join(
f"{m['name']} (applies to {', '.join(m['objects']) or 'any object'})"
for m in attribute_classifications
)
search_objects_properties["attribute"] = {
"type": "string",
"description": (
"Attribute label produced by a configured classification model "
"(case-sensitive)."
"Filter by a classification attribute label produced by a "
"configured attribute classification model. Use this INSTEAD "
"of semantic_query when the user's request matches one of "
"these classifications. Configured models: "
f"{model_outline}. "
"Set the value to the attribute label that matches the user's "
"phrasing (case-sensitive)."
),
}
@@ -316,12 +334,29 @@ def get_tool_definitions(
search_objects_properties["semantic_query"] = {
"type": "string",
"description": (
"Description of an appearance or activity, used to semantically "
"narrow results."
"Optional natural-language description of a PHYSICAL "
"CHARACTERISTIC, APPEARANCE, or ACTIVITY the user mentioned, "
"used to semantically narrow results. Only set this when the "
"user describes something beyond what label and sub_label can "
"express on their own.\n"
"USE for descriptive phrases like: 'riding a lawn mower', "
"'wearing a red jacket', 'carrying a package', 'walking a "
"dog', 'on a bicycle', 'holding an umbrella'.\n"
"DO NOT USE for:\n"
"- specific named people, pets, or delivery companies → use sub_label\n"
"- animal species or breed names like 'blue jay', 'cardinal', "
"'golden retriever' → use sub_label\n"
"- license plate strings → use sub_label\n"
"- generic object queries like 'all cars today' or 'every "
"person' → use label alone with no semantic_query\n"
"When set, combine with label/time/camera/zone filters as "
"usual (e.g. label='person', semantic_query='riding a lawn "
"mower', after='2024-05-01T00:00:00Z')."
+ (
" The configured embeddings model only understands English, so "
"always write this in English, translating the user's "
"description if they phrased it in another language."
" The configured embeddings model only understands "
"English, so always write semantic_query in English, "
"translating the user's description if they phrased it "
"in another language."
if embeddings_language == "english"
else ""
)
@@ -329,10 +364,26 @@ def get_tool_definitions(
}
search_objects_description = (
"Search the historical record of tracked detections. Use this ONLY for "
"questions about the PAST, e.g. 'did anyone come by today?', 'when was the "
"last car?'. For alerting on future events use start_camera_watch instead."
"Search the historical record of detected objects in Frigate. "
"Use this ONLY for questions about the PAST e.g. 'did anyone come by today?', "
"'when was the last car?', 'show me detections from yesterday'. "
"Do NOT use this for monitoring or alerting requests about future events — "
"use start_camera_watch instead for those. "
"An 'object' in Frigate represents a tracked detection (e.g., a person, package, car).\n\n"
"Choose filters based on what the user is asking for:\n"
"- Generic class query ('show me all cars today'): set `label` only.\n"
"- Specific NAMED entity (known person, delivery company, animal "
"species/breed like 'blue jay' or 'golden retriever', license "
"plate): set `sub_label` only and leave `label` unset.\n"
)
if semantic_search_enabled:
search_objects_description += (
"- Physical CHARACTERISTIC, APPEARANCE, or ACTIVITY that is not a "
"discrete name ('person riding a lawn mower', 'someone in a red "
"jacket', 'person carrying a package'): set `semantic_query` with "
"the descriptive phrase, optionally alongside `label` for the "
"object class. Do NOT put descriptive phrases in sub_label."
)
return [
{
@@ -347,30 +398,20 @@ def get_tool_definitions(
"required": [],
},
},
{
"type": "function",
"function": {
"name": "get_categorized_object_names",
"description": (
"Every name that can be attached as a sub_label, grouped by object "
"type: recognized faces, named license plates, classification "
"categories, and delivery logos. Takes no arguments and always "
"returns the complete map."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "find_similar_objects",
"description": (
"Find tracked objects visually and semantically similar to a "
"specific past event. Requires semantic search to be enabled."
"Find tracked objects that are visually and semantically similar "
"to a specific past event. Use this when the user references a "
"particular object they have seen and wants to find other "
"sightings of the same or similar one ('that green car', 'the "
"person in the red jacket', 'the package that was delivered'). "
"Prefer this over search_objects whenever the user's intent is "
"'find more like this specific one.' Use search_objects first "
"only if you need to locate the anchor event. Requires semantic "
"search to be enabled."
),
"parameters": {
"type": "object",
@@ -432,8 +473,9 @@ def get_tool_definitions(
"function": {
"name": "set_camera_state",
"description": (
"Change a camera's feature state, e.g. turn detection on or off. "
"Only call this when the user explicitly asks to change a setting. "
"Change a camera's feature state (e.g., turn detection on/off, enable/disable recordings). "
"Use camera='*' to apply to all cameras at once. "
"Only call this tool when the user explicitly asks to change a camera setting. "
"Requires admin privileges."
),
"parameters": {
@@ -475,7 +517,7 @@ def get_tool_definitions(
},
"value": {
"type": "string",
"description": "The value to set, as accepted by the chosen feature.",
"description": "The value to set. ON or OFF for toggles, a number for thresholds, a profile name or 'none' for profile.",
},
},
"required": ["camera", "feature", "value"],
@@ -487,9 +529,11 @@ def get_tool_definitions(
"function": {
"name": "get_live_context",
"description": (
"Current live image and detections (tracked objects, zones, "
"timestamps) for one camera. Use this for questions about what is "
"happening right now. Call it again for each additional camera."
"Get the current live image and detection information for a single camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera. "
"Operates on one camera at a time; call the tool again for each additional camera. "
"Wildcards and empty values are not accepted."
),
"parameters": {
"type": "object",
@@ -497,8 +541,8 @@ def get_tool_definitions(
"camera": {
"type": "string",
"description": (
"Exact name of a single camera. Wildcards (e.g. '*', "
"'all') and empty strings are not accepted."
"Exact name of a single camera to get live context for. "
"Wildcards (e.g. '*', 'all') and empty strings are not accepted."
),
},
},
@@ -511,9 +555,10 @@ def get_tool_definitions(
"function": {
"name": "start_camera_watch",
"description": (
"Start a continuous watch job that monitors a camera and notifies "
"the user when a condition is met, e.g. 'tell me when guests "
"arrive'. Only one watch job can run at a time. Returns a job ID."
"Start a continuous VLM watch job that monitors a camera and sends a notification "
"when a specified condition is met. Use this when the user wants to be alerted about "
"a future event, e.g. 'tell me when guests arrive' or 'notify me when the package is picked up'. "
"Only one watch job can run at a time. Returns a job ID."
),
"parameters": {
"type": "object",
@@ -553,7 +598,10 @@ def get_tool_definitions(
"type": "function",
"function": {
"name": "stop_camera_watch",
"description": "Cancel the currently running watch job.",
"description": (
"Cancel the currently running VLM watch job. Use this when the user wants to "
"stop a previously started watch, e.g. 'stop watching the front door'."
),
"parameters": {
"type": "object",
"properties": {},
@@ -566,9 +614,11 @@ def get_tool_definitions(
"function": {
"name": "get_profile_status",
"description": (
"Get the active profile and when each profile was last activated. "
"Call this before get_recap to derive the time window for requests "
"like 'what happened while I was away?'."
"Get the current profile status including the active profile and "
"timestamps of when each profile was last activated. Use this to "
"determine time periods for recap requests — e.g. when the user asks "
"'what happened while I was away?', call this first to find the relevant "
"time window based on profile activation history."
),
"parameters": {
"type": "object",
@@ -582,9 +632,11 @@ def get_tool_definitions(
"function": {
"name": "get_recap",
"description": (
"Get all activity (alerts and detections) for a time period, as a "
"chronological list with camera, objects, zones, and descriptions "
"when available. Summarize the results for the user."
"Get a recap of all activity (alerts and detections) for a given time period. "
"Use this after calling get_profile_status to retrieve what happened during "
"a specific window — e.g. 'what happened while I was away?'. Returns a "
"chronological list of activity with camera, objects, zones, and GenAI-generated "
"descriptions when available. Summarize the results for the user."
),
"parameters": {
"type": "object",
@@ -671,13 +723,14 @@ def build_chat_system_prompt(
)
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
filter_routing_section = (
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
"- Generic class ('show me all cars today'): set `label` only.\n"
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset. Call get_categorized_object_names first and use the exact spelling it returns; a guessed spelling matches nothing. If the name is absent, say it is not configured rather than searching for it."
)
semantic_search_section = ""
if semantic_search_enabled:
filter_routing_section += "\n- Physical characteristic, appearance, or activity that is NOT a discrete name ('riding a lawn mower', 'someone in a red jacket'): set `semantic_query` with the descriptive phrase, optionally combined with `label`. Never put descriptive phrases in `sub_label`."
semantic_search_section = (
"\n\nWhen routing a search_objects call, pick filters by the shape of the user's request:\n"
"- Generic class ('show me all cars today'): set `label` only.\n"
"- Specific named entity — a known person ('John'), delivery company ('Amazon'), animal species/breed ('blue jay', 'cardinal', 'golden retriever'), or license plate: set `sub_label` only and leave `label` unset.\n"
"- Physical characteristic, appearance, or activity that is NOT a discrete name ('find me people riding a lawn mower', 'someone in a red jacket', 'a person carrying a package'): set `semantic_query` with the descriptive phrase, optionally combined with `label` for the object class. Never put descriptive phrases in `sub_label`."
)
attribute_classification_section = ""
if attribute_classifications:
@@ -686,9 +739,9 @@ def build_chat_system_prompt(
for m in attribute_classifications
)
attribute_classification_section = (
"\n\nConfigured attribute classification models:\n"
"\n\nAttribute classification models are configured for the following object types:\n"
f"{model_lines}\n"
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label (case-sensitive) rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases outside the configured attribute labels."
"When the user's request matches one of these classifications, set the search_objects `attribute` field to the matching label rather than using `semantic_query`. Reserve `semantic_query` for descriptive phrases that fall outside the configured attribute labels."
)
return f"""You are a helpful assistant for Frigate, a security camera NVR system. You help users answer questions about their cameras, detected objects, and events.
@@ -697,6 +750,9 @@ Current server local date and time: {current_date_str} at {current_time_str}
Do not start your response with phrases like "I will check...", "Let me see...", or "Let me look...". Answer directly.
Always present times in the server's local timezone. When tool results include start_time_local and end_time_local, quote those strings exactly; never convert or invent timestamps, and fall back to UTC or ISO format only when a result has no local time fields. Resolve relative dates like "today" or "this week" against the current date above, and pass dates to tools in ISO 8601 (e.g. {current_date_str}T00:00:00Z for the start of today).
Always present times to the user in the server's local timezone. When tool results include start_time_local and end_time_local, use those exact strings when listing or describing detection times—do not convert or invent timestamps. Do not use UTC or ISO format with Z for the user-facing answer unless the tool result only provides Unix timestamps without local time fields.
When users ask about "today", "yesterday", "this week", etc., use the current date above as reference.
When searching for objects or events, use ISO 8601 format for dates (e.g., {current_date_str}T00:00:00Z for the start of today).
Always be accurate with time calculations based on the current date provided.
When the user refers to a specific object they have seen ("that green car", "the person in the red jacket", "a package left today"), prefer find_similar_objects over search_objects, using search_objects only to locate the anchor event and passing its id along. Keep search_objects for generic queries like "show me all cars today". If a user message begins with [attached_event:<id>], treat that id as the anchor for any similarity or "tell me more" request in the same message.{filter_routing_section}{attribute_classification_section}{cameras_section}{speed_units_section}"""
When a user refers to a specific object they have seen or describe with identifying details ("that green car", "the person in the red jacket", "a package left today"), prefer the find_similar_objects tool over search_objects. Use search_objects first only to locate the anchor event, then pass its id to find_similar_objects. For generic queries like "show me all cars today", keep using search_objects. If a user message begins with [attached_event:<id>], treat that event id as the anchor for any similarity or "tell me more" request in the same message and call find_similar_objects with that id.{semantic_search_section}{attribute_classification_section}{cameras_section}{speed_units_section}"""
+12 -8
View File
@@ -625,14 +625,18 @@ class OnvifController:
return
self.cams[camera_name]["active"] = True
self.ptz_metrics[camera_name].motor_stopped.clear()
logger.debug(
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}"
)
self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[
camera_name
].frame_time.value
self.ptz_metrics[camera_name].stop_time.value = 0
# only track start_time for autotracking
if self.ptz_metrics[camera_name].autotracker_enabled.value:
self.ptz_metrics[camera_name].motor_stopped.clear()
logger.debug(
f"{camera_name}: PTZ start time: {self.ptz_metrics[camera_name].frame_time.value}"
)
self.ptz_metrics[camera_name].start_time.value = self.ptz_metrics[
camera_name
].frame_time.value
self.ptz_metrics[camera_name].stop_time.value = 0
move_request = self.cams[camera_name]["relative_move_request"]
# function takes in -1 to 1 for pan and tilt, interpolate to the values of the camera.
@@ -0,0 +1,174 @@
"""Tests that the internal port trusted by /auth cannot be moved at runtime."""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
import ruamel.yaml
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.const import JWT_SECRET_ENV_VAR
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
class TestAuthInternalPort(BaseTestHttp):
"""/auth grants anonymous admin by port, so that port must stay put.
nginx binds its listeners once at container start and never reloads them,
but /api/config/set can swap the live config object mid-process. If /auth
read the port off the live config, saving networking.listen.internal would
hand unauthenticated admin to whoever can reach the external port.
"""
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"auth": {"enabled": True},
"networking": {"listen": {"internal": 5000, "external": 8971}},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
def _create_app(self):
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
return {
"username": request.headers.get("remote-user"),
"role": request.headers.get("remote-role"),
}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app
def _write_config_file(self):
"""Write the minimal config to a temp YAML file and return the path."""
yaml = ruamel.yaml.YAML()
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
yaml.dump(self.minimal_config, f)
f.close()
return f.name
def test_internal_port_is_anonymous_admin(self):
app = self._create_app()
with AuthTestClient(app) as client:
resp = client.get("/auth", headers={"x-server-port": "5000"})
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-user"], "anonymous")
self.assertEqual(resp.headers["remote-role"], "admin")
def test_external_port_requires_auth(self):
app = self._create_app()
with AuthTestClient(app) as client:
resp = client.get("/auth", headers={"x-server-port": "8971"})
self.assertEqual(resp.status_code, 401)
def test_swapped_config_does_not_move_the_trusted_port(self):
"""The live config is not what /auth trusts.
Stands in for every path that can rebind app.frigate_config while the
process runs, whatever restart flag the caller claimed.
"""
app = self._create_app()
swapped = FrigateConfig(
**{
**self.minimal_config,
"networking": {"listen": {"internal": 8971, "external": 5000}},
}
)
app.frigate_config = swapped
with AuthTestClient(app) as client:
resp = client.get("/auth", headers={"x-server-port": "8971"})
self.assertEqual(resp.status_code, 401)
# nginx is still listening where it was told to at boot
resp = client.get("/auth", headers={"x-server-port": "5000"})
self.assertEqual(resp.status_code, 202)
self.assertEqual(resp.headers["remote-role"], "admin")
@patch("frigate.api.app.find_config_file")
def test_config_set_rejects_internal_matching_external(self, mock_find_config):
"""Saving the internal port onto the external one is refused outright."""
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app = self._create_app()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"networking": {"listen": {"internal": 8971}}},
"update_topic": "config/networking",
"requires_restart": 1,
},
)
self.assertEqual(resp.status_code, 400)
self.assertFalse(resp.json()["success"])
# the rejected save must not have reached the live config
self.assertEqual(
app.frigate_config.networking.listen.internal_port, 5000
)
resp = client.get("/auth", headers={"x-server-port": "8971"})
self.assertEqual(resp.status_code, 401)
with open(config_path) as f:
self.assertNotIn("8971", f.read().split("external")[0])
finally:
os.unlink(config_path)
if __name__ == "__main__":
unittest.main(verbosity=2)
-174
View File
@@ -1,73 +1,15 @@
"""Unit tests for recordings/media API endpoints."""
from dataclasses import dataclass
from datetime import UTC, datetime
from unittest.mock import patch
import pytz
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.const import MAX_SEGMENT_DURATION
from frigate.models import Recordings
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@dataclass(frozen=True)
class RangeCase:
"""Expected behavior for one segment relative to the requested range.
Offsets are seconds from REQUEST_START; the request ends at +100 seconds.
"""
name: str
start_offset: float
end_offset: float
included_in_recordings: bool
vod_clip_from_ms: int | None = None
vod_duration_ms: int | None = None
REQUEST_START = 1000
REQUEST_END = 1100
RANGE_CASES = (
RangeCase("before", -MAX_SEGMENT_DURATION + 1, -1, False),
RangeCase("meets_start", -10, 0, True),
RangeCase(
"overlaps_start",
-MAX_SEGMENT_DURATION + 0.5,
0.25,
True,
vod_clip_from_ms=599500,
vod_duration_ms=250,
),
RangeCase("starts_at_start", 0, 10, True, vod_duration_ms=10000),
RangeCase("inside", 20, 80, True, vod_duration_ms=60000),
RangeCase("ends_at_end", 90, 100, True, vod_duration_ms=10000),
RangeCase("matches_range", 0, 100, True, vod_duration_ms=100000),
RangeCase("starts_with_range", 0, 110, True, vod_duration_ms=100000),
RangeCase(
"covers_range",
-20,
120,
True,
vod_clip_from_ms=20000,
vod_duration_ms=100000,
),
RangeCase(
"ends_with_range",
-10,
100,
True,
vod_clip_from_ms=10000,
vod_duration_ms=100000,
),
RangeCase("overlaps_end", 95, 105, True, vod_duration_ms=5000),
RangeCase("starts_at_end", 100, 110, True),
RangeCase("after", 101, 110, False),
)
class TestHttpMedia(BaseTestHttp):
"""Test media API endpoints, particularly recordings with DST handling."""
@@ -102,26 +44,6 @@ class TestHttpMedia(BaseTestHttp):
self.app.dependency_overrides.clear()
super().tearDown()
def _assert_vod_response(
self,
response,
expected_clips: list[tuple[str, int | None, int]],
) -> None:
"""Assert VOD clip metadata and its derived duration fields."""
assert response.status_code == 200
vod = response.json()
assert [
(
clip["path"],
clip.get("clipFrom"),
clip["keyFrameDurations"][0],
)
for clip in vod["sequences"][0]["clips"]
] == expected_clips
expected_durations = [clip[2] for clip in expected_clips]
assert vod["durations"] == expected_durations
assert vod["segment_duration"] == max(expected_durations)
def test_recordings_summary_across_dst_spring_forward(self):
"""
Test recordings summary across spring DST transition (spring forward).
@@ -482,102 +404,6 @@ class TestHttpMedia(BaseTestHttp):
assert "2024-03-10" in summary
assert summary["2024-03-10"] is True
def test_recordings_handles_all_range_relations(self):
"""Recordings return every interval relation that touches the range."""
with AuthTestClient(self.app) as client:
for case in RANGE_CASES:
with self.subTest(case=case.name):
Recordings.delete().execute()
super().insert_mock_recording(
case.name,
REQUEST_START + case.start_offset,
REQUEST_START + case.end_offset,
)
response = client.get(
"/front_door/recordings",
params={"after": REQUEST_START, "before": REQUEST_END},
)
assert response.status_code == 200
expected_ids = [case.name] if case.included_in_recordings else []
assert [
recording["id"] for recording in response.json()
] == expected_ids
def test_vod_handles_all_range_relations(self):
"""VOD clips every interval relation with positive playback duration."""
with (
AuthTestClient(self.app) as client,
patch(
"frigate.api.media.get_keyframe_before",
side_effect=lambda _path, offset: offset,
),
):
for case in RANGE_CASES:
with self.subTest(case=case.name):
Recordings.delete().execute()
super().insert_mock_recording(
case.name,
REQUEST_START + case.start_offset,
REQUEST_START + case.end_offset,
)
response = client.get(
f"/vod/front_door/start/{REQUEST_START}/end/{REQUEST_END}"
)
if case.vod_duration_ms is None:
assert response.status_code == 404
continue
self._assert_vod_response(
response,
[
(
case.name,
case.vod_clip_from_ms,
case.vod_duration_ms,
)
],
)
def test_vod_handles_segment_ending_at_start_with_keyframe_fallbacks(self):
"""VOD keeps a boundary segment when keyframe lookup extends it."""
def keyframe_before(path: str, offset: int) -> int | None:
return offset - 1000 if path == "previous_keyframe" else None
with (
AuthTestClient(self.app) as client,
patch(
"frigate.api.media.get_keyframe_before",
side_effect=keyframe_before,
),
):
super().insert_mock_recording(
"previous_keyframe",
REQUEST_START - 10,
REQUEST_START,
)
super().insert_mock_recording(
"missing_keyframe",
REQUEST_START - 5,
REQUEST_START,
)
response = client.get(
f"/vod/front_door/start/{REQUEST_START}/end/{REQUEST_END}"
)
self._assert_vod_response(
response,
[
("previous_keyframe", 9000, 1000),
("missing_keyframe", None, 5000),
],
)
def test_recordings_unavailable_reports_gap_between_recordings(self):
"""A gap between two recordings is reported as an unavailable segment."""
with AuthTestClient(self.app) as client:
@@ -5,6 +5,7 @@ import tempfile
import unittest
from unittest.mock import MagicMock, patch
from frigate.app import FrigateApp
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.runtime_state import RuntimeStatePersistence
@@ -363,5 +364,94 @@ class TestReapplyRuntimeStateToConfig(unittest.TestCase):
dispatcher.reapply_runtime_state_to_config()
class TestStartupAppliesConfigLayersBeforeWorkersStart(unittest.TestCase):
"""Both layers must reach the config before config-carrying workers start.
A worker started before a layer is applied keeps the yaml value for the
rest of the session: the config_updater broadcast sent later is dropped
for subscribers that have not connected yet, and nothing re-sends it.
"""
CONFIG_LAYERS = (
"profile_manager.restore_persisted_profile_to_config",
"dispatcher.reapply_runtime_state_to_config",
)
# started with a copy of the camera config
CONFIG_CARRYING_WORKERS = (
"start_video_output_processor",
"start_ptz_autotracker",
"start_detected_frames_processor",
"start_camera_processor",
"start_audio_processor",
)
def _start_call_order(self) -> list[str]:
"""Return the names FrigateApp.start() calls, in order."""
app = MagicMock()
with (
patch("frigate.app.set_file_limit"),
patch("frigate.app.cleanup_replay_cameras"),
patch("frigate.app.reap_stale_exports"),
patch("frigate.app.create_fastapi_app"),
patch("frigate.app.uvicorn"),
):
FrigateApp.start(app)
return [name for name, _, _ in app.mock_calls]
def test_applied_before_any_config_carrying_worker(self) -> None:
order = self._start_call_order()
for layer in self.CONFIG_LAYERS:
for worker in self.CONFIG_CARRYING_WORKERS:
self.assertLess(order.index(layer), order.index(worker))
def test_applied_after_the_dispatcher_exists(self) -> None:
order = self._start_call_order()
for layer in self.CONFIG_LAYERS:
self.assertLess(order.index("init_dispatcher"), order.index(layer))
def test_applied_after_the_profile_base_is_snapshotted(self) -> None:
# ProfileManager snapshots the config as the "no profile" base that
# deactivation resets to, so neither layer may be in the config yet
order = self._start_call_order()
for layer in self.CONFIG_LAYERS:
self.assertLess(order.index("init_profile_manager"), order.index(layer))
def test_layers_applied_in_order(self) -> None:
# a runtime toggle is the layer the user set last, so it goes on top
order = self._start_call_order()
self.assertLess(
order.index("profile_manager.restore_persisted_profile_to_config"),
order.index("dispatcher.reapply_runtime_state_to_config"),
)
def test_overrides_still_re_applied_after_the_profile_is_restored(self) -> None:
# activation resets the sections it owns to the base first, so the
# overrides have to land on top again
order = self._start_call_order()
self.assertLess(
order.index("profile_manager.restore_persisted_profile"),
order.index("dispatcher.restore_runtime_state"),
)
def test_broadcast_replay_still_runs_at_the_end(self) -> None:
# the broadcast is the only channel for the recording, review, and
# embeddings processes, which start before the config can be corrected
order = self._start_call_order()
for replay in (
"profile_manager.restore_persisted_profile",
"dispatcher.restore_runtime_state",
):
self.assertLess(order.index("start_audio_processor"), order.index(replay))
if __name__ == "__main__":
unittest.main()
+1 -88
View File
@@ -1,12 +1,7 @@
import unittest
from io import StringIO
from unittest.mock import MagicMock, patch
from frigate.util.services import (
get_amd_gpu_stats,
get_intel_gpu_stats,
get_openvino_npu_stats,
)
from frigate.util.services import get_amd_gpu_stats, get_intel_gpu_stats
class TestGpuStats(unittest.TestCase):
@@ -22,88 +17,6 @@ class TestGpuStats(unittest.TestCase):
amd_stats = get_amd_gpu_stats()
assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"}
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.time", side_effect=[0.0, 1.0])
@patch(
"frigate.util.services.os.readlink",
return_value="/sys/bus/pci/drivers/intel_vpu",
)
@patch(
"frigate.util.services.glob.glob",
return_value=["/sys/class/accel/accel0"],
)
@patch(
"builtins.open",
side_effect=[StringIO("1000"), StringIO("1250")],
)
def test_openvino_npu_stats_discovers_accel0(
self, open_file, glob, readlink, time, sleep
):
assert get_openvino_npu_stats() == {"npu": "25.0", "mem": "-%"}
open_file.assert_any_call(
"/sys/class/accel/accel0/device/power/runtime_active_time"
)
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.time", side_effect=[0.0, 1.0])
@patch(
"frigate.util.services.os.readlink",
side_effect=[
"/sys/bus/pci/drivers/other",
"/sys/bus/pci/drivers/intel_vpu",
],
)
@patch(
"frigate.util.services.glob.glob",
return_value=[
"/sys/class/accel/accel0",
"/sys/class/accel/accel1",
],
)
@patch(
"builtins.open",
side_effect=[StringIO("1000"), StringIO("1250")],
)
def test_openvino_npu_stats_skips_non_intel_accelerator(
self, open_file, glob, readlink, time, sleep
):
assert get_openvino_npu_stats() == {"npu": "25.0", "mem": "-%"}
open_file.assert_any_call(
"/sys/class/accel/accel1/device/power/runtime_active_time"
)
@patch(
"frigate.util.services.os.readlink",
return_value="/sys/bus/pci/drivers/other",
)
@patch(
"frigate.util.services.glob.glob",
return_value=["/sys/class/accel/accel0"],
)
@patch("builtins.open")
def test_openvino_npu_stats_no_intel_accelerator(self, open_file, glob, readlink):
assert get_openvino_npu_stats() is None
open_file.assert_not_called()
@patch(
"frigate.util.services.os.readlink",
return_value="/sys/bus/pci/drivers/intel_vpu",
)
@patch(
"frigate.util.services.glob.glob",
return_value=["/sys/class/accel/accel0"],
)
@patch("builtins.open", side_effect=FileNotFoundError)
def test_openvino_npu_stats_runtime_counter_unavailable(
self, open_file, glob, readlink
):
assert get_openvino_npu_stats() is None
open_file.assert_called_once_with(
"/sys/class/accel/accel0/device/power/runtime_active_time"
)
@patch("frigate.stats.intel_gpu_info.intel_gpu_name_resolver.get_names")
@patch("frigate.util.services.time.sleep")
@patch("frigate.util.services.time.monotonic")
+41
View File
@@ -0,0 +1,41 @@
"""Tests for networking config validation."""
import unittest
from pydantic import ValidationError
from frigate.config.network import ListenConfig
class TestListenConfig(unittest.TestCase):
def test_defaults_are_distinct(self):
listen = ListenConfig()
self.assertEqual(listen.internal_port, 5000)
self.assertEqual(listen.external_port, 8971)
def test_address_and_port_string_is_parsed(self):
listen = ListenConfig(internal="127.0.0.1:5000", external="0.0.0.0:8971")
self.assertEqual(listen.internal_port, 5000)
self.assertEqual(listen.external_port, 8971)
def test_identical_ports_rejected(self):
with self.assertRaises(ValidationError):
ListenConfig(internal=8971, external=8971)
def test_same_port_on_different_addresses_rejected(self):
# nginx would accept these as distinct listeners, but /auth decides on
# the port alone, so the external one would inherit anonymous admin
with self.assertRaises(ValidationError):
ListenConfig(internal="127.0.0.1:8971", external="0.0.0.0:8971")
def test_distinct_ports_accepted(self):
listen = ListenConfig(internal=5001, external="0.0.0.0:8971")
self.assertEqual(listen.internal_port, 5001)
self.assertEqual(listen.external_port, 8971)
if __name__ == "__main__":
unittest.main(verbosity=2)
+226
View File
@@ -0,0 +1,226 @@
"""Tests for detector post-processing NMS box format handling.
cv2.dnn.NMSBoxes expects boxes as [x, y, width, height]. Passing corner
coordinates [x1, y1, x2, y2] makes OpenCV treat x2/y2 as width/height,
inflating every box toward the bottom-right by its distance from the origin.
Two well separated objects far from the origin then appear to overlap and the
lower scoring one is silently suppressed.
The regression geometry used throughout: two boxes with zero true overlap,
A = (393, 499, 484, 620) and B = (527, 499, 618, 620) in a 640x640 input
(43 px gap). Misread as [x, y, w, h] their IoU is 0.465, above the 0.4 NMS
threshold, so the buggy format drops the lower scoring box while correct
conversion keeps both.
"""
import math
import unittest
from queue import Queue
import numpy as np
from frigate.detectors.plugins.memryx import MemryXDetector
from frigate.util.model import (
post_process_dfine,
post_process_rfdetr,
post_process_yolo,
post_process_yolox,
)
WIDTH = 640
HEIGHT = 640
# box A: xyxy (393, 499, 484, 620) as center format
A_CX, A_CY, A_W, A_H = 438.5, 559.5, 91.0, 121.0
# box B: xyxy (527, 499, 618, 620) as center format
B_CX, B_CY, B_W, B_H = 572.5, 559.5, 91.0, 121.0
# expected normalized output rows: [class_id, conf, y1, x1, y2, x2]
A_ROW = [499 / 640, 393 / 640, 620 / 640, 484 / 640]
B_ROW = [499 / 640, 527 / 640, 620 / 640, 618 / 640]
def kept(detections: np.ndarray) -> np.ndarray:
"""Rows of the padded (20, 6) output that hold real detections."""
return detections[detections[:, 1] > 0]
class TestYoloNmsPostProcess(unittest.TestCase):
def _single_output(self, rows: list[list[float]]) -> list[np.ndarray]:
"""Build a single-tensor YOLO output (1, attrs, anchors) from
[cx, cy, w, h, class scores...] rows, padded with empty anchors."""
anchors = np.zeros((10, len(rows[0])), dtype=np.float32)
anchors[: len(rows)] = np.array(rows, dtype=np.float32)
return [anchors.T[np.newaxis, ...]]
def test_keeps_separated_objects_far_from_origin(self):
output = self._single_output(
[
[A_CX, A_CY, A_W, A_H, 0.90, 0.0],
[B_CX, B_CY, B_W, B_H, 0.0, 0.85],
]
)
detections = kept(post_process_yolo(output, WIDTH, HEIGHT))
self.assertEqual(len(detections), 2)
np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3)
np.testing.assert_allclose(detections[1], [1, 0.85, *B_ROW], atol=2e-3)
def test_still_suppresses_true_duplicates(self):
# same object twice, shifted 4 px: true IoU 0.92, must dedupe to one
output = self._single_output(
[
[A_CX, A_CY, A_W, A_H, 0.90, 0.0],
[A_CX + 4, A_CY, A_W, A_H, 0.85, 0.0],
]
)
detections = kept(post_process_yolo(output, WIDTH, HEIGHT))
self.assertEqual(len(detections), 1)
np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3)
class TestMultipartYoloPostProcess(unittest.TestCase):
def _multipart_output(self) -> list[np.ndarray]:
"""Build a 3-scale anchor-based YOLO output containing boxes A and B,
both decoded through anchor 0 of the stride-32 scale."""
outputs = [
np.zeros((1, 255, 80, 80), dtype=np.float32),
np.zeros((1, 255, 40, 40), dtype=np.float32),
np.zeros((1, 255, 20, 20), dtype=np.float32),
]
stride, (anchor_w, anchor_h) = 32, (142, 110)
for cx, cy, w, h, conf, class_channel in [
(A_CX, A_CY, A_W, A_H, 0.95, 5), # class 0
(B_CX, B_CY, B_W, B_H, 0.90, 6), # class 1
]:
cell_x, cell_y = int(cx // stride), int(cy // stride)
dx = (cx / stride - cell_x + 0.5) / 2
dy = (cy / stride - cell_y + 0.5) / 2
dw = math.sqrt(w / anchor_w) / 2
dh = math.sqrt(h / anchor_h) / 2
# anchor 0 occupies channels 0-84 of the 255 channel tensor
outputs[2][0, 0:4, cell_y, cell_x] = [dx, dy, dw, dh]
outputs[2][0, 4, cell_y, cell_x] = conf
outputs[2][0, class_channel, cell_y, cell_x] = 1.0
return outputs
def test_keeps_separated_objects_far_from_origin(self):
detections = kept(post_process_yolo(self._multipart_output(), WIDTH, HEIGHT))
self.assertEqual(len(detections), 2)
np.testing.assert_allclose(detections[0], [0, 0.95, *A_ROW], atol=2e-3)
np.testing.assert_allclose(detections[1], [1, 0.90, *B_ROW], atol=2e-3)
def test_empty_output_returns_no_detections(self):
outputs = [
np.zeros((1, 255, 80, 80), dtype=np.float32),
np.zeros((1, 255, 40, 40), dtype=np.float32),
np.zeros((1, 255, 20, 20), dtype=np.float32),
]
detections = kept(post_process_yolo(outputs, WIDTH, HEIGHT))
self.assertEqual(len(detections), 0)
class TestYoloxPostProcess(unittest.TestCase):
def test_keeps_separated_objects_far_from_origin(self):
# with zero grids and unit strides the decode reduces to
# cx = raw cx and w = exp(raw w)
rows = np.zeros((10, 7), dtype=np.float32)
rows[0] = [A_CX, A_CY, math.log(A_W), math.log(A_H), 1.0, 0.90, 0.0]
rows[1] = [B_CX, B_CY, math.log(B_W), math.log(B_H), 1.0, 0.0, 0.85]
predictions = rows[np.newaxis, ...]
grids = np.zeros((1, 10, 2), dtype=np.float32)
expanded_strides = np.ones((1, 10, 1), dtype=np.float32)
detections = kept(
post_process_yolox(predictions, WIDTH, HEIGHT, grids, expanded_strides)
)
self.assertEqual(len(detections), 2)
np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3)
np.testing.assert_allclose(detections[1], [1, 0.85, *B_ROW], atol=2e-3)
class TestDfinePostProcess(unittest.TestCase):
def test_keeps_separated_objects_far_from_origin(self):
# D-FINE emits absolute pixel xyxy boxes alongside labels and scores
labels = np.zeros((1, 10), dtype=np.int64)
labels[0, 1] = 1
boxes = np.zeros((1, 10, 4), dtype=np.float32)
boxes[0, 0] = [393, 499, 484, 620]
boxes[0, 1] = [527, 499, 618, 620]
scores = np.zeros((1, 10), dtype=np.float32)
scores[0, 0] = 0.90
scores[0, 1] = 0.85
detections = kept(post_process_dfine([labels, boxes, scores], WIDTH, HEIGHT))
self.assertEqual(len(detections), 2)
np.testing.assert_allclose(detections[0], [0, 0.90, *A_ROW], atol=2e-3)
np.testing.assert_allclose(detections[1], [1, 0.85, *B_ROW], atol=2e-3)
class TestRfdetrPostProcess(unittest.TestCase):
def test_keeps_separated_objects_far_from_origin(self):
# RF-DETR emits normalized center format boxes and class logits where
# logit index 0 is the background class
boxes = np.zeros((1, 10, 4), dtype=np.float32)
boxes[0, 0] = [A_CX / WIDTH, A_CY / HEIGHT, A_W / WIDTH, A_H / HEIGHT]
boxes[0, 1] = [B_CX / WIDTH, B_CY / HEIGHT, B_W / WIDTH, B_H / HEIGHT]
# background heavy logits everywhere, then two confident objects
logits = np.tile(np.array([10.0, 0.0, 0.0], dtype=np.float32), (1, 10, 1))
logits[0, 0] = [0.0, 4.0, 0.0] # class 0 after background offset
logits[0, 1] = [0.0, 0.0, 3.5] # class 1 after background offset
detections = kept(post_process_rfdetr([boxes, logits]))
conf_a = math.exp(4.0) / (math.exp(4.0) + 2)
conf_b = math.exp(3.5) / (math.exp(3.5) + 2)
self.assertEqual(len(detections), 2)
np.testing.assert_allclose(detections[0], [0, conf_a, *A_ROW], atol=2e-3)
np.testing.assert_allclose(detections[1], [1, conf_b, *B_ROW], atol=2e-3)
class TestMemryxSsdlitePostProcess(unittest.TestCase):
def test_keeps_separated_objects_far_from_origin(self):
# the NMS math runs on the host CPU, so the real method is testable
# without MemryX hardware; it only needs the model dimensions and
# the output queue
detector = object.__new__(MemryXDetector)
detector.memx_model_width = WIDTH
detector.memx_model_height = HEIGHT
detector.output_queue = Queue()
# this path uses a 0.5 NMS threshold, so use a tighter pair: zero
# true overlap (10 px gap), IoU 0.69 when misread as [x, y, w, h]
dets = np.zeros((1, 10, 5), dtype=np.float32)
dets[0, 0] = [480, 500, 540, 620, 0.90]
dets[0, 1] = [550, 500, 610, 620, 0.85]
labels = np.zeros((1, 10), dtype=np.float32)
labels[0, 1] = 1
detector.post_process_ssdlite([dets, labels])
detections = kept(detector.output_queue.get())
self.assertEqual(len(detections), 2)
np.testing.assert_allclose(
detections[0],
[0, 0.90, 500 / 640, 480 / 640, 620 / 640, 540 / 640],
atol=2e-3,
)
np.testing.assert_allclose(
detections[1],
[1, 0.85, 500 / 640, 550 / 640, 620 / 640, 610 / 640],
atol=2e-3,
)
if __name__ == "__main__":
unittest.main()
+92
View File
@@ -785,6 +785,98 @@ class TestProfileManager(unittest.TestCase):
manager.activate_profile("armed", clear_runtime_overrides=False)
dispatcher.clear_runtime_state.assert_not_called()
def test_apply_profile_to_config_mutates_the_config(self):
"""The config-only half applies the same overrides as activation."""
err = self.manager.apply_profile_to_config("armed")
assert err is None
front = self.config.cameras["front"]
assert front.notifications.enabled is True
assert front.objects.track == ["person", "car", "package"]
def test_apply_profile_to_config_makes_no_zmq_mqtt_or_disk_writes(self):
"""Workers are started with the values, so nothing is published yet."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
with patch.object(ProfileManager, "_persist_active_profile") as mock_persist:
manager.apply_profile_to_config("armed")
self.mock_updater.publish_update.assert_not_called()
dispatcher.publish.assert_not_called()
mock_persist.assert_not_called()
# bookkeeping stays with activate_profile
assert self.config.active_profile is None
def test_apply_profile_to_config_rejects_an_unknown_profile(self):
err = self.manager.apply_profile_to_config("nonexistent")
assert err is not None
assert "not defined" in err
def test_restore_persisted_profile_to_config_applies_it(self):
"""The startup config pass restores what was persisted."""
with patch.object(
ProfileManager, "load_persisted_profile", return_value="armed"
):
self.manager.restore_persisted_profile_to_config()
assert self.config.cameras["front"].notifications.enabled is True
# still the config-only half, so nothing is published or persisted
self.mock_updater.publish_update.assert_not_called()
assert self.config.active_profile is None
def test_restore_persisted_profile_to_config_no_op_when_none_persisted(self):
with patch.object(ProfileManager, "load_persisted_profile", return_value=None):
self.manager.restore_persisted_profile_to_config()
assert self.config.cameras["front"].notifications.enabled is False
def test_restore_persisted_profile_to_config_ignores_a_stale_name(self):
"""A profile no longer offered by any camera must not be applied."""
with patch.object(
ProfileManager, "load_persisted_profile", return_value="ghost"
):
self.manager.restore_persisted_profile_to_config()
assert self.config.cameras["front"].notifications.enabled is False
@patch.object(ProfileManager, "_persist_active_profile")
def test_restore_persisted_profile_activates_and_publishes(self, mock_persist):
"""The startup publish pass runs a full activation."""
dispatcher = MagicMock()
manager = ProfileManager(self.config, self.mock_updater, dispatcher)
with patch.object(
ProfileManager, "load_persisted_profile", return_value="armed"
):
manager.restore_persisted_profile()
assert self.config.active_profile == "armed"
self.mock_updater.publish_update.assert_called()
# a startup replay must not wipe the runtime overrides layered on top
dispatcher.clear_runtime_state.assert_not_called()
@patch.object(ProfileManager, "_persist_active_profile")
def test_activation_after_apply_still_publishes_every_section(self, mock_persist):
"""Re-deriving the same state must not skip the broadcast.
The processes that started before the config was corrected have no
other channel.
"""
self.manager.apply_profile_to_config("armed")
self.mock_updater.publish_update.reset_mock()
err = self.manager.activate_profile("armed", clear_runtime_overrides=False)
assert err is None
published = {
call.args[0].update_type.name
for call in self.mock_updater.publish_update.call_args_list
}
assert "notifications" in published
assert "objects" in published
assert self.config.active_profile == "armed"
@patch.object(ProfileManager, "_persist_active_profile")
def test_update_config_preserves_runtime_state_with_active_profile(
self, mock_persist
+92 -1
View File
@@ -1,4 +1,4 @@
"""Tests for ONVIF init state that must not depend on the autotracking config.
"""Tests for ONVIF state that must not depend on the autotracking config.
Regression coverage for a camera that is initialized while autotracking is off and
has it enabled later, which is the normal wizard flow: set the camera up first,
@@ -10,12 +10,17 @@ the tracking thread.
The request objects are built from the locally parsed WSDL and cost no network, so
they are always created and init=True now implies they exist.
Also covers the inverse direction: the ptz movement timestamps must not be written
for a camera that has autotracking off, because nothing clears them back out.
"""
import unittest
from unittest.mock import AsyncMock, MagicMock
from frigate.camera import PTZMetrics
from frigate.config import FrigateConfig
from frigate.ptz.autotrack import ptz_moving_at_frame_time
from frigate.ptz.onvif import OnvifController
CAMERA = "ptz_cam"
@@ -97,6 +102,36 @@ def _make_controller(autotracking_enabled: bool) -> OnvifController:
return controller
def _make_move_controller(autotracking_enabled: bool) -> OnvifController:
"""Build an already initialized controller for a camera that supports relative
FOV movement, with real metrics so the timestamp writes can be asserted on."""
config = _config(autotracking_enabled)
controller = OnvifController.__new__(OnvifController)
controller.config = config
controller.camera_configs = {CAMERA: config.cameras[CAMERA]}
controller.failed_cams = {}
ptz = MagicMock()
ptz.RelativeMove = AsyncMock()
controller.cams = {
CAMERA: {
"init": True,
"active": False,
"ptz": ptz,
"features": ["pt", "pt-r-fov"],
"relative_move_request": MagicMock(),
"relative_fov_range": {
"XRange": {"Min": -1.0, "Max": 1.0},
"YRange": {"Min": -1.0, "Max": 1.0},
},
}
}
controller.ptz_metrics = {
CAMERA: PTZMetrics(autotracker_enabled=autotracking_enabled)
}
return controller
class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
async def test_status_request_created_when_autotracking_disabled(self) -> None:
# the wizard flow: onvif configured first, autotracking enabled later
@@ -143,5 +178,61 @@ class TestOnvifInitRequests(unittest.IsolatedAsyncioTestCase):
ptz.GetStatus.assert_not_called()
class TestManualRelativeMoveMetrics(unittest.IsolatedAsyncioTestCase):
"""A manual move from the UI (click to move, drag to zoom) sends move_relative
for any camera that advertises pt-r-fov, autotracking or not."""
async def test_metrics_untouched_when_autotracking_disabled(self) -> None:
# only camera_maintenance polls get_camera_status, and only for autotracking
# cameras, so a manual move that starts the clock here is never stopped
controller = _make_move_controller(autotracking_enabled=False)
metrics = controller.ptz_metrics[CAMERA]
metrics.frame_time.value = 1000.0
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
controller.cams[CAMERA]["ptz"].RelativeMove.assert_awaited_once()
self.assertEqual(metrics.start_time.value, 0)
self.assertEqual(metrics.stop_time.value, 0)
self.assertTrue(metrics.motor_stopped.is_set())
async def test_detection_regions_not_suppressed_after_manual_move(self) -> None:
# the symptom of the bug: object detection stops entirely because motion
# boxes are never promoted to detection regions again
controller = _make_move_controller(autotracking_enabled=False)
metrics = controller.ptz_metrics[CAMERA]
metrics.frame_time.value = 1000.0
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
for later_frame_time in (1001.0, 1060.0, 4600.0):
with self.subTest(frame_time=later_frame_time):
self.assertFalse(
ptz_moving_at_frame_time(
later_frame_time,
metrics.start_time.value,
metrics.stop_time.value,
)
)
async def test_metrics_written_when_autotracking_enabled(self) -> None:
# get_camera_status resets stop_time once the camera reports IDLE, so the
# autotracking path keeps its motion estimation timestamps
controller = _make_move_controller(autotracking_enabled=True)
metrics = controller.ptz_metrics[CAMERA]
metrics.frame_time.value = 1000.0
await controller._move_relative(CAMERA, 0.25, -0.25, 0, 1)
self.assertEqual(metrics.start_time.value, 1000.0)
self.assertEqual(metrics.stop_time.value, 0)
self.assertFalse(metrics.motor_stopped.is_set())
self.assertTrue(
ptz_moving_at_frame_time(
1001.0, metrics.start_time.value, metrics.stop_time.value
)
)
if __name__ == "__main__":
unittest.main()
+37 -5
View File
@@ -16,6 +16,31 @@ logger = logging.getLogger(__name__)
### Post Processing
def xyxy_to_xywh_for_nms(boxes: np.ndarray | list) -> np.ndarray:
"""Convert [x1, y1, x2, y2] boxes to the [x, y, width, height] format
that cv2.dnn.NMSBoxes expects.
Passing corner coordinates directly makes OpenCV treat x2/y2 as the box
size, inflating every box toward the bottom-right by its distance from
the origin, which suppresses valid detections near other objects.
Args:
boxes: Array-like of shape (N, 4) in corner format.
Returns:
Float32 array of shape (N, 4) in top-left plus size format.
"""
boxes = np.asarray(boxes, dtype=np.float32)
if boxes.size == 0:
return np.zeros((0, 4), dtype=np.float32)
xywh = boxes.copy()
xywh[:, 2] -= xywh[:, 0]
xywh[:, 3] -= xywh[:, 1]
return xywh
def post_process_dfine(
tensor_output: np.ndarray, width: int, height: int
) -> np.ndarray:
@@ -25,7 +50,9 @@ def post_process_dfine(
input_shape = np.array([height, width, height, width])
boxes = np.divide(boxes, input_shape, dtype=np.float32)
indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.4, nms_threshold=0.4)
indices = cv2.dnn.NMSBoxes(
xyxy_to_xywh_for_nms(boxes), scores, score_threshold=0.4, nms_threshold=0.4
)
detections = np.zeros((20, 6), np.float32)
for i, (bbox, confidence, class_id) in enumerate(
@@ -78,7 +105,10 @@ def post_process_rfdetr(tensor_output: list[np.ndarray, np.ndarray]) -> np.ndarr
# apply nms
indices = cv2.dnn.NMSBoxes(
filtered_boxes, filtered_scores, score_threshold=0.4, nms_threshold=0.4
xyxy_to_xywh_for_nms(filtered_boxes),
filtered_scores,
score_threshold=0.4,
nms_threshold=0.4,
)
detections = np.zeros((20, 6), np.float32)
@@ -159,7 +189,7 @@ def __post_process_multipart_yolo(
all_class_ids.append(class_id)
indices = cv2.dnn.NMSBoxes(
bboxes=all_boxes,
bboxes=xyxy_to_xywh_for_nms(all_boxes),
scores=all_scores,
score_threshold=0.4,
nms_threshold=0.4,
@@ -206,7 +236,9 @@ def __post_process_nms_yolo(predictions: np.ndarray, width, height) -> np.ndarra
boxes = boxes_xyxy
# run NMS
indices = cv2.dnn.NMSBoxes(boxes, scores, score_threshold=0.4, nms_threshold=0.4)
indices = cv2.dnn.NMSBoxes(
xyxy_to_xywh_for_nms(boxes), scores, score_threshold=0.4, nms_threshold=0.4
)
detections = np.zeros((20, 6), np.float32)
for i, (bbox, confidence, class_id) in enumerate(
zip(boxes[indices], scores[indices], class_ids[indices])
@@ -258,7 +290,7 @@ def post_process_yolox(
scores = scores[np.arange(len(cls_inds)), cls_inds]
indices = cv2.dnn.NMSBoxes(
boxes_xyxy, scores, score_threshold=0.4, nms_threshold=0.4
xyxy_to_xywh_for_nms(boxes_xyxy), scores, score_threshold=0.4, nms_threshold=0.4
)
detections = np.zeros((20, 6), np.float32)
-209
View File
@@ -1,209 +0,0 @@
"""Aggregation of the known sub label names an object can be tagged with."""
import logging
import os
from pathvalidate import sanitize_filename
from frigate.config import FrigateConfig
from frigate.config.classification import ObjectClassificationType
from frigate.const import CLIPS_DIR, FACE_DIR, MODEL_CACHE_DIR
from frigate.util.builtin import load_labels
logger = logging.getLogger(__name__)
# subdirectory of FACE_DIR holding unassigned training images, not a face name
FACE_TRAIN_DIR = "train"
# category used by classification models for "no match", never attached to an object
CLASSIFICATION_NONE_CATEGORY = "none"
def get_categorized_object_names(
config: FrigateConfig,
allowed_cameras: list[str],
object_type: str | None = None,
) -> dict[str, list[str]]:
"""Collect every sub label name this install can attach, by object type.
Unlike the database-backed /sub_labels endpoint, this reads the config and
model files, so it also covers names that are configured but have not been
detected yet. Names come from the detector's logo attributes (limited to
objects the allowed cameras actually track), LPR known plate names,
registered face names, and custom object classification categories.
Structural attributes such as `face` and `license_plate` are excluded: they
describe a part of an object rather than naming it, and are never attached
as a sub label.
Args:
config: The running Frigate config
allowed_cameras: Cameras the requesting user may see
object_type: Optional object label to restrict the result to
Returns:
Mapping of object label to its known sub label names, sorted and
deduplicated. Object types with no known names are omitted.
"""
tracked_objects = _get_tracked_objects(config, allowed_cameras)
names: dict[str, set[str]] = {}
logos = set(config.model.all_attribute_logos)
# 1. detector logo attributes, only for objects that are actually tracked
for label, label_attributes in config.model.attributes_map.items():
if label not in tracked_objects:
continue
label_logos = logos.intersection(label_attributes)
if label_logos:
names.setdefault(label, set()).update(label_logos)
# 2. LPR known plate names, for objects that can carry a plate
if config.lpr.known_plates and _lpr_enabled(config, allowed_cameras):
known_plates = set(config.lpr.known_plates)
for label in _objects_with_attribute(config, tracked_objects, "license_plate"):
names.setdefault(label, set()).update(known_plates)
# 3. registered face names, for objects that can carry a face
if _face_recognition_enabled(config, allowed_cameras):
face_names = _get_face_names()
if face_names:
for label in _objects_with_attribute(config, tracked_objects, "face"):
names.setdefault(label, set()).update(face_names)
# 4. custom object classification categories
for model_key, model_config in config.classification.custom.items():
if not model_config.enabled or model_config.object_config is None:
continue
if (
model_config.object_config.classification_type
!= ObjectClassificationType.sub_label
):
continue
categories = _get_classification_categories(model_key)
if not categories:
continue
for label in model_config.object_config.objects:
names.setdefault(label, set()).update(categories)
return {
label: sorted(label_names)
for label, label_names in sorted(names.items())
if label_names and (object_type is None or label == object_type)
}
def _get_tracked_objects(config: FrigateConfig, allowed_cameras: list[str]) -> set[str]:
"""Get the union of objects tracked by the cameras the user can see."""
tracked: set[str] = set()
for camera_name in allowed_cameras:
camera_config = config.cameras.get(camera_name)
if camera_config is None:
continue
tracked.update(camera_config.objects.track)
return tracked
def _objects_with_attribute(
config: FrigateConfig, tracked_objects: set[str], attribute: str
) -> set[str]:
"""Get the tracked objects that a given attribute can be recognized on.
The attribute may also be tracked as an object in its own right, as
`license_plate` is on a dedicated LPR camera, in which case the name is
attached to that object directly.
"""
objects = {
label
for label, label_attributes in config.model.attributes_map.items()
if attribute in label_attributes and label in tracked_objects
}
if attribute in tracked_objects:
objects.add(attribute)
return objects
def _lpr_enabled(config: FrigateConfig, allowed_cameras: list[str]) -> bool:
return any(
config.cameras[camera_name].lpr.enabled
for camera_name in allowed_cameras
if camera_name in config.cameras
)
def _face_recognition_enabled(
config: FrigateConfig, allowed_cameras: list[str]
) -> bool:
return any(
config.cameras[camera_name].face_recognition.enabled
for camera_name in allowed_cameras
if camera_name in config.cameras
)
def _get_face_names() -> set[str]:
"""Get the names of every registered face collection."""
if not os.path.exists(FACE_DIR):
return set()
try:
entries = os.listdir(FACE_DIR)
except OSError:
logger.debug("Failed to read face directory %s", FACE_DIR)
return set()
return {
name
for name in entries
if name != FACE_TRAIN_DIR and os.path.isdir(os.path.join(FACE_DIR, name))
}
def _get_classification_categories(model_key: str) -> set[str]:
"""Get the categories a custom classification model can output.
The trained labelmap is authoritative, but it only exists once the model
has been trained, so fall back to the dataset directories that will become
the labelmap on the next training run.
"""
safe_key = sanitize_filename(model_key)
categories: set[str] = set()
labelmap_path = os.path.join(MODEL_CACHE_DIR, safe_key, "labelmap.txt")
if os.path.exists(labelmap_path):
try:
labelmap = load_labels(labelmap_path, prefill=0, indexed=False)
except OSError:
logger.debug("Failed to read labelmap %s", labelmap_path)
labelmap = {}
categories.update(label for label in labelmap.values() if label)
dataset_dir = os.path.join(CLIPS_DIR, safe_key, "dataset")
if os.path.exists(dataset_dir):
try:
entries = os.listdir(dataset_dir)
except OSError:
logger.debug("Failed to read dataset directory %s", dataset_dir)
entries = []
categories.update(
name for name in entries if os.path.isdir(os.path.join(dataset_dir, name))
)
categories.discard(CLASSIFICATION_NONE_CATEGORY)
return categories
+5 -20
View File
@@ -1,7 +1,6 @@
"""Utilities for services."""
import asyncio
import glob
import json
import logging
import os
@@ -671,33 +670,19 @@ def get_intel_gpu_stats(
def get_openvino_npu_stats() -> dict[str, str] | None:
"""Get NPU stats using openvino."""
for accel_path in sorted(glob.glob("/sys/class/accel/accel*")):
try:
driver = os.path.basename(os.readlink(f"{accel_path}/device/driver"))
except OSError:
continue
if driver != "intel_vpu":
continue
try:
runtime_path = f"{accel_path}/device/power/runtime_active_time"
with open(runtime_path) as f:
initial_runtime = float(f.read().strip())
break
except (FileNotFoundError, PermissionError, ValueError):
continue
else:
return None
NPU_RUNTIME_PATH = "/sys/devices/pci0000:00/0000:00:0b.0/power/runtime_active_time"
try:
with open(NPU_RUNTIME_PATH) as f:
initial_runtime = float(f.read().strip())
initial_time = time.time()
# Sleep for 1 second to get an accurate reading
time.sleep(1.0)
# Read runtime value again
with open(runtime_path) as f:
with open(NPU_RUNTIME_PATH) as f:
current_runtime = float(f.read().strip())
current_time = time.time()
+11 -6
View File
@@ -358,12 +358,17 @@ def process_frames(
]
# only add in the motion boxes when not calibrating and a ptz is not moving via autotracking
# ptz_moving_at_frame_time() always returns False for non-autotracking cameras
if not motion_detector.is_calibrating() and not ptz_moving_at_frame_time(
frame_time,
ptz_metrics.start_time.value,
ptz_metrics.stop_time.value,
):
# the ptz timestamps are only maintained while autotracking is on, so gate
# on the metric rather than trusting them to be reset otherwise
ptz_moving = ptz_metrics.autotracker_enabled.value and (
ptz_moving_at_frame_time(
frame_time,
ptz_metrics.start_time.value,
ptz_metrics.stop_time.value,
)
)
if not motion_detector.is_calibrating() and not ptz_moving:
# find motion boxes that are not inside tracked object regions
standalone_motion_boxes = [
b for b in motion_boxes if not inside_any(b, regions)
@@ -0,0 +1,204 @@
/**
* Camera live playback stream settings tests -- MEDIUM tier.
*
* The live streams field maps a display name to a go2rtc stream. Switching
* cameras from the selector keeps the form mounted and only swaps its data, so
* the stream name input has to follow the newly selected camera. It used to be
* an uncontrolled input, which left the previous camera's stream name on screen
* and renamed the wrong key if the stale text was ever committed.
*
* Renames are committed per keystroke so the section is marked as modified
* right away, except while the typed name belongs to another stream, since
* renaming onto an existing name merges the two entries.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const GO2RTC_STREAMS = {
front_door_main: ["rtsp://user:pass@192.168.0.20:554/Stream1"],
backyard_main: ["rtsp://user:pass@192.168.0.21:554/Stream1"],
};
const CAMERA_LIVE_STREAMS = {
front_door: { front_door: "front_door_main" },
backyard: { backyard: "backyard_main" },
};
const SETTINGS_URL = "/settings?page=cameraLivePlayback&camera=front_door";
async function installRoutes(
page: Page,
frontDoorStreams: Record<string, string> = CAMERA_LIVE_STREAMS.front_door,
) {
const config = configFactory({
go2rtc: { streams: GO2RTC_STREAMS },
cameras: {
front_door: { live: { streams: frontDoorStreams } },
backyard: { live: { streams: CAMERA_LIVE_STREAMS.backyard } },
},
});
let lastSavedConfig: unknown = null;
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ json: config });
}
return route.fulfill({ json: { success: true } });
});
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({
json: {
go2rtc: { streams: GO2RTC_STREAMS },
cameras: {
front_door: { live: { streams: frontDoorStreams } },
backyard: { live: { streams: CAMERA_LIVE_STREAMS.backyard } },
},
},
}),
);
await page.route("**/api/config/set", async (route) => {
lastSavedConfig = route.request().postDataJSON();
await route.fulfill({ json: { success: true, require_restart: false } });
});
return { capturedConfig: () => lastSavedConfig };
}
async function selectCamera(page: Page, friendlyName: string) {
await page.getByRole("button", { name: "Select a camera" }).click();
await page.getByRole("switch", { name: friendlyName }).click();
}
function streamNameInputs(page: Page) {
return page.getByRole("textbox", { name: "Stream name" });
}
function streamNames(page: Page) {
return streamNameInputs(page).evaluateAll((inputs) =>
inputs.map((input) => (input as HTMLInputElement).value),
);
}
/** Rows render in config order, which is not the order they were declared in. */
async function streamNameRow(page: Page, name: string) {
await expect.poll(() => streamNames(page)).toContain(name);
const names = await streamNames(page);
return streamNameInputs(page).nth(names.indexOf(name));
}
test.describe("camera live playback streams @medium", () => {
test("switching cameras updates the stream name field", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
const streamName = frigateApp.page.getByRole("textbox", {
name: "Stream name",
});
await expect(streamName).toHaveValue("front_door");
await expect(
frigateApp.page.getByRole("combobox", { name: "go2rtc stream" }),
).toContainText("front_door_main");
await selectCamera(frigateApp.page, "Backyard");
await expect(streamName).toHaveValue("backyard");
await expect(
frigateApp.page.getByRole("combobox", { name: "go2rtc stream" }),
).toContainText("backyard_main");
});
test("typing a new name enables Save without leaving the field", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
const save = frigateApp.page.getByRole("button", { name: "Save" });
await expect(save).toBeDisabled();
const streamName = await streamNameRow(frigateApp.page, "front_door");
await streamName.click();
await frigateApp.page.keyboard.press("End");
await frigateApp.page.keyboard.type("_hd");
// Still focused: the rename is committed per keystroke, not on blur.
await expect(save).toBeEnabled();
await expect(streamName).toBeFocused();
await expect(streamName).toHaveValue("front_door_hd");
});
test("typing through another stream's name keeps both streams", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, {
front: "front_door_main",
front_door: "backyard_main",
});
await frigateApp.goto(SETTINGS_URL);
const streamName = await streamNameRow(frigateApp.page, "front_door");
await streamName.click();
await frigateApp.page.keyboard.press("End");
// "front_door" passes through "front", which the other row already uses.
await frigateApp.page.keyboard.press("Backspace");
await frigateApp.page.keyboard.press("Backspace");
await frigateApp.page.keyboard.press("Backspace");
await frigateApp.page.keyboard.press("Backspace");
await frigateApp.page.keyboard.press("Backspace");
await expect(streamName).toHaveValue("front");
await frigateApp.page.keyboard.type("yard");
await streamName.blur();
expect(await streamNames(frigateApp.page)).toEqual(["frontyard", "front"]);
});
test("renaming a stream saves the new name for the selected camera", async ({
frigateApp,
}) => {
const capture = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await selectCamera(frigateApp.page, "Backyard");
const streamName = frigateApp.page.getByRole("textbox", {
name: "Stream name",
});
await expect(streamName).toHaveValue("backyard");
await streamName.fill("Backyard HD");
// The rename is committed on blur, not on every keystroke.
await streamName.blur();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => capture.capturedConfig(), { timeout: 5_000 })
.toMatchObject({
config_data: {
cameras: {
backyard: {
live: { streams: { "Backyard HD": "backyard_main" } },
},
},
},
});
});
});
+13 -13
View File
@@ -71,7 +71,7 @@
"react-icons": "^5.5.0",
"react-konva": "^19.2.3",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.30.3",
"react-router-dom": "^6.30.4",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0",
@@ -4878,9 +4878,9 @@
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -12282,12 +12282,12 @@
}
},
"node_modules/react-router": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2"
"@remix-run/router": "1.23.3"
},
"engines": {
"node": ">=14.0.0"
@@ -12297,13 +12297,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
"@remix-run/router": "1.23.3",
"react-router": "6.30.4"
},
"engines": {
"node": ">=14.0.0"
+1 -1
View File
@@ -85,7 +85,7 @@
"react-icons": "^5.5.0",
"react-konva": "^19.2.3",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.30.3",
"react-router-dom": "^6.30.4",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0",
+2 -2
View File
@@ -859,8 +859,8 @@
"description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later."
},
"dashboard": {
"label": "Show in UI",
"description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again."
"label": "Show on Live dashboard",
"description": "Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings."
},
"review": {
"label": "Show in review",
+2 -18
View File
@@ -357,22 +357,6 @@
"description": "Optional API key for authenticated DeepStack services."
}
},
"degirum": {
"label": "DeGirum",
"description": "DeGirum detector for running models via DeGirum cloud or local inference services.",
"location": {
"label": "Inference Location",
"description": "Location of the DeGirim inference engine (e.g. '@cloud', '127.0.0.1')."
},
"zoo": {
"label": "Model Zoo",
"description": "Path or URL to the DeGirum model zoo."
},
"token": {
"label": "DeGirum Cloud Token",
"description": "Token for DeGirum Cloud access."
}
},
"edgetpu": {
"label": "EdgeTPU",
"description": "EdgeTPU detector that runs TensorFlow Lite models compiled for Coral EdgeTPU using the EdgeTPU delegate.",
@@ -1543,8 +1527,8 @@
"description": "Numeric order used to sort the camera in the UI (default dashboard and lists); larger numbers appear later."
},
"dashboard": {
"label": "Show in UI",
"description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again."
"label": "Show on Live dashboard",
"description": "Toggle whether this camera is visible on the default All Cameras live dashboard. The camera remains available everywhere else in the UI, including camera groups and settings."
},
"review": {
"label": "Show in review",
+5 -2
View File
@@ -74,7 +74,7 @@
"menuItem": "View motion previews",
"title": "Motion previews: {{camera}}",
"mobileSettingsTitle": "Motion Preview Settings",
"mobileSettingsDesc": "Adjust playback speed and dimming, and choose a date to review motion-only clips.",
"mobileSettingsDesc": "Adjust playback speed, dimming, and cropping, and choose a date to review motion-only clips.",
"dim": "Dim",
"dimAria": "Adjust dimming intensity",
"dimDesc": "Increase dimming to increase motion area visibility.",
@@ -87,6 +87,9 @@
"seekAria": "Seek {{camera}} player to {{time}}",
"filter": "Filter",
"filterDesc": "Select areas to only show clips with motion in those regions.",
"filterClear": "Clear"
"filterClear": "Clear",
"crop": "Crop to filter",
"cropAria": "Toggle cropping previews to the filtered areas",
"cropDesc": "Zoom previews into the selected filter areas instead of showing the full frame."
}
}
+1 -1
View File
@@ -499,7 +499,7 @@
"webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.",
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com).",
"dashboardLabel": "Show on Live dashboard",
"dashboardHelp": "Show this camera on the Live dashboard.",
"dashboardHelp": "Show this camera on the default All Cameras live dashboard. It remains available everywhere else, including camera groups.",
"reviewLabel": "Show in Review",
"reviewHelp": "Show this camera in Review, including the camera filter, motion review, and the history view."
}
@@ -5,6 +5,7 @@
import { canExpand } from "@rjsf/utils";
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LuPlus, LuChevronDown, LuChevronRight } from "react-icons/lu";
import { useTranslation } from "react-i18next";
import {
@@ -12,7 +13,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
interface AddPropertyButtonProps {
/** Callback fired when the add button is clicked */
@@ -67,6 +68,72 @@ export function AddPropertyButton({
);
}
interface MapKeyInputProps {
/** DOM id used for label association */
id: string;
/** The committed key as it exists in the form data */
value: string;
/** Placeholder shown when the input is empty */
placeholder?: string;
/** Whether the input is disabled */
disabled?: boolean;
/** Additional class names */
className?: string;
/** Called with the edited key when it is safe to commit */
onCommit: (next: string) => void;
/** Whether another entry already uses this key, which defers the commit */
isKeyTaken?: (next: string) => boolean;
}
/**
* Text input for the key of a map entry (e.g. a live stream name).
*
* The edit is kept in local state so that the draft can be re-synced whenever
* the committed key changes underneath the input, which is what happens when
* the selected camera changes while the field stays mounted.
*
* Each keystroke is committed so the section is marked as modified right away,
* except while the typed key belongs to another entry: renaming onto an
* existing key merges the two entries, so a name typed through a neighbor's
* name would silently drop it. Those keystrokes stay local until the key is
* free again or the input is blurred.
*/
export function MapKeyInput({
id,
value,
placeholder,
disabled,
className,
onCommit,
isKeyTaken,
}: MapKeyInputProps) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
const handleChange = (next: string) => {
setDraft(next);
if (!isKeyTaken?.(next)) {
onCommit(next);
}
};
return (
<Input
id={id}
value={draft}
placeholder={placeholder}
disabled={disabled}
className={className}
onChange={(e) => handleChange(e.target.value)}
onBlur={() => onCommit(draft)}
/>
);
}
interface AdvancedCollapsibleProps {
/** Number of advanced fields */
count: number;
@@ -19,6 +19,7 @@ import {
import type { ConfigFormContext } from "@/types/configForm";
import get from "lodash/get";
import { isSubtreeModified } from "../utils";
import { MapKeyInput } from "../components";
type KnownPlatesData = Record<string, string[]>;
@@ -194,12 +195,16 @@ export function KnownPlatesField(props: FieldProps) {
className="space-y-2 rounded-md border p-3"
>
<div className="flex items-center gap-2">
<Input
<MapKeyInput
id={`${entryId}-key`}
defaultValue={key}
value={key}
placeholder={namePlaceholder}
disabled={disabled || readonly}
onBlur={(e) => handleRenameKey(key, e.target.value)}
onCommit={(next) => handleRenameKey(key, next)}
isKeyTaken={(next) =>
next !== key &&
Object.prototype.hasOwnProperty.call(data, next)
}
className="flex-1"
/>
<Button
@@ -3,7 +3,6 @@ import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Command,
@@ -20,6 +19,7 @@ import {
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import { MapKeyInput } from "../components";
import type { ConfigFormContext } from "@/types/configForm";
import get from "lodash/get";
import { isSubtreeModified } from "../utils";
@@ -288,12 +288,16 @@ export function LiveStreamsField(props: FieldProps) {
>
<div className="col-span-12 space-y-2 md:col-span-5">
<Label htmlFor={`${entryId}-key`}>{streamNameLabel}</Label>
<Input
<MapKeyInput
id={`${entryId}-key`}
defaultValue={key}
value={key}
placeholder={streamNamePlaceholder}
disabled={disabled || readonly}
onBlur={(e) => handleRenameKey(key, e.target.value)}
onCommit={(next) => handleRenameKey(key, next)}
isKeyTaken={(next) =>
next !== key &&
Object.prototype.hasOwnProperty.call(data, next)
}
/>
</div>
<div className="col-span-10 space-y-2 md:col-span-6">
@@ -1,16 +1,20 @@
import { baseUrl } from "@/api/baseUrl";
import { useCallback, useRef } from "react";
import { CameraConfig } from "@/types/frigateConfig";
import { useCallback, useMemo, useRef } from "react";
const GRID_SIZE = 16;
const DEFAULT_ASPECT_RATIO = 16 / 9;
// Cap how tall the grid can get for portrait and 4:3 cameras
const MAX_GRID_HEIGHT = "65dvh";
type MotionRegionFilterGridProps = {
cameraName: string;
camera: CameraConfig;
selectedCells: Set<number>;
onCellsChange: (cells: Set<number>) => void;
};
export default function MotionRegionFilterGrid({
cameraName,
camera,
selectedCells,
onCellsChange,
}: MotionRegionFilterGridProps) {
@@ -21,6 +25,18 @@ export default function MotionRegionFilterGrid({
const lastCellRef = useRef<number>(-1);
const gridRef = useRef<HTMLDivElement>(null);
// Cells are indexed against the detect frame, so the grid has to match the
// frame's aspect ratio or painted cells land on the wrong part of the image
const aspectRatio = useMemo(() => {
if (!camera.detect.width || !camera.detect.height) {
return DEFAULT_ASPECT_RATIO;
}
const ratio = camera.detect.width / camera.detect.height;
return Number.isFinite(ratio) && ratio > 0 ? ratio : DEFAULT_ASPECT_RATIO;
}, [camera.detect.height, camera.detect.width]);
const toggleCell = useCallback(
(index: number, forceAdd?: boolean) => {
const next = new Set(selectedCells);
@@ -109,13 +125,17 @@ export default function MotionRegionFilterGrid({
return (
<div className="space-y-2">
<div
className="relative aspect-video w-full select-none overflow-hidden rounded-lg"
style={{ touchAction: "none" }}
className="relative mx-auto select-none overflow-hidden rounded-lg"
style={{
aspectRatio,
width: `min(100%, calc(${MAX_GRID_HEIGHT} * ${aspectRatio}))`,
touchAction: "none",
}}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<img
src={`${baseUrl}api/${cameraName}/latest.jpg?h=500`}
src={`${baseUrl}api/${camera.name}/latest.jpg?h=500`}
className="absolute inset-0 size-full object-contain"
draggable={false}
alt=""
+1 -1
View File
@@ -78,7 +78,7 @@ export default function ZoneEditPane({
}
return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled_in_config)
.filter((conf) => conf.enabled_in_config)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
@@ -327,17 +327,20 @@ export function ReviewTimeline({
documentInstance?.addEventListener("touchmove", handleMouseMove);
documentInstance?.addEventListener("mouseup", handleMouseUp);
documentInstance?.addEventListener("touchend", handleMouseUp);
documentInstance?.addEventListener("touchcancel", handleMouseUp);
} else {
documentInstance?.removeEventListener("mousemove", handleMouseMove);
documentInstance?.removeEventListener("touchmove", handleMouseMove);
documentInstance?.removeEventListener("mouseup", handleMouseUp);
documentInstance?.removeEventListener("touchend", handleMouseUp);
documentInstance?.removeEventListener("touchcancel", handleMouseUp);
}
return () => {
documentInstance?.removeEventListener("mousemove", handleMouseMove);
documentInstance?.removeEventListener("touchmove", handleMouseMove);
documentInstance?.removeEventListener("mouseup", handleMouseUp);
documentInstance?.removeEventListener("touchend", handleMouseUp);
documentInstance?.removeEventListener("touchcancel", handleMouseUp);
};
}, [handleMouseMove, handleMouseUp, isDragging]);
@@ -73,7 +73,9 @@ export const VirtualizedEventSegments = forwardRef<
Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) +
OVERSCAN_COUNT,
);
setVisibleRange({ start, end });
setVisibleRange((prev) =>
prev.start === start && prev.end === end ? prev : { start, end },
);
}
}, [segments.length, timelineRef]);
@@ -77,7 +77,9 @@ export const VirtualizedMotionSegments = forwardRef<
Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) +
OVERSCAN_COUNT,
);
setVisibleRange({ start, end });
setVisibleRange((prev) =>
prev.start === start && prev.end === end ? prev : { start, end },
);
}
}, [segments.length, timelineRef]);
+27 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTimelineUtils } from "./use-timeline-utils";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
@@ -8,6 +8,8 @@ import { useTimeFormat } from "./use-date-utils";
import { useTranslation } from "react-i18next";
import useUserInteraction from "./use-user-interaction";
const DRAG_STATE_COMMIT_MS = 100;
type DraggableElementProps = {
contentRef: React.RefObject<HTMLElement | null>;
timelineRef: React.RefObject<HTMLDivElement | null>;
@@ -61,6 +63,8 @@ function useDraggableElement({
const [clientYPosition, setClientYPosition] = useState<number | null>(null);
const [initialClickAdjustment, setInitialClickAdjustment] = useState(0);
const lastDragTimeCommitRef = useRef(0);
const pendingDragTimeRef = useRef<number | null>(null);
const [elementScrollIntoView, setElementScrollIntoView] = useState(true);
const [scrollEdgeSize, setScrollEdgeSize] = useState<number>();
const [fullTimelineHeight, setFullTimelineHeight] = useState<number>();
@@ -126,6 +130,7 @@ function useDraggableElement({
}
e.stopPropagation();
setIsDragging(true);
pendingDragTimeRef.current = null;
let clientY;
if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) {
@@ -154,9 +159,14 @@ function useDraggableElement({
if (isDragging) {
setIsDragging(false);
setInitialClickAdjustment(0);
if (pendingDragTimeRef.current !== null && setDraggableElementTime) {
setDraggableElementTime(pendingDragTimeRef.current);
pendingDragTimeRef.current = null;
}
}
},
[isDragging, setIsDragging],
[isDragging, setIsDragging, setDraggableElementTime],
);
const timestampToPixels = useCallback(
@@ -346,9 +356,21 @@ function useDraggableElement({
);
if (setDraggableElementTime) {
setDraggableElementTime(
targetSegmentTime + segmentDuration * (offset / segmentHeight),
);
const newTime =
targetSegmentTime + segmentDuration * (offset / segmentHeight);
const now = performance.now();
// don't commit on every animation frame, only commit it at a
// set interval to avoid React's nested update limit
if (now - lastDragTimeCommitRef.current >= DRAG_STATE_COMMIT_MS) {
lastDragTimeCommitRef.current = now;
pendingDragTimeRef.current = null;
setDraggableElementTime(newTime);
} else {
// Hold the newest value; handleMouseUp flushes it so the
// release still lands exactly where the handle was dropped.
pendingDragTimeRef.current = newTime;
}
}
if (draggingAtTopEdge || draggingAtBottomEdge) {
+7 -1
View File
@@ -8,6 +8,7 @@ function useUserInteraction({ elementRef }: UseUserInteractionProps) {
const [userInteracting, setUserInteracting] = useState(false);
const interactionTimeout = useRef<NodeJS.Timeout>(undefined);
const isProgrammaticScroll = useRef(false);
const userInteractingRef = useRef(false);
const setProgrammaticScroll = useCallback(() => {
isProgrammaticScroll.current = true;
@@ -16,13 +17,18 @@ function useUserInteraction({ elementRef }: UseUserInteractionProps) {
useEffect(() => {
const handleUserInteraction = () => {
if (!isProgrammaticScroll.current) {
setUserInteracting(true);
// Only commit state on the leading edge
if (!userInteractingRef.current) {
userInteractingRef.current = true;
setUserInteracting(true);
}
if (interactionTimeout.current) {
clearTimeout(interactionTimeout.current);
}
interactionTimeout.current = setTimeout(() => {
userInteractingRef.current = false;
setUserInteracting(false);
}, 3000);
} else {
+42 -23
View File
@@ -198,6 +198,19 @@ export default function Events() {
return true;
});
const reviewCamerasParam = useMemo(() => {
const selected: string | undefined = reviewSearchParams["cameras"];
if (!selected) {
return reviewCameras.join(",");
}
const selectedCameras = new Set(selected.split(","));
return reviewCameras
.filter((camera) => selectedCameras.has(camera))
.join(",");
}, [reviewCameras, reviewSearchParams]);
useSearchEffect("labels", (labels: string) => {
setReviewFilter({
...reviewFilter,
@@ -330,8 +343,12 @@ export default function Events() {
}, []);
const getKey = useCallback(() => {
if (!timezone) {
return null;
}
const params = {
cameras: reviewSearchParams["cameras"],
cameras: reviewCamerasParam,
labels: reviewSearchParams["labels"],
zones: reviewSearchParams["zones"],
reviewed: null, // We want both reviewed and unreviewed items as we filter in the UI
@@ -339,7 +356,7 @@ export default function Events() {
after: reviewSearchParams["after"] || last24Hours.after,
};
return ["review", params];
}, [reviewSearchParams, last24Hours]);
}, [reviewSearchParams, reviewCamerasParam, last24Hours, timezone]);
const { data: reviews, mutate: updateSegments } = useSWR<ReviewSegment[]>(
getKey,
@@ -361,10 +378,6 @@ export default function Events() {
const motion: ReviewSegment[] = [];
reviews?.forEach((segment) => {
if (config?.cameras[segment.camera]?.ui?.review === false) {
return;
}
all.push(segment);
switch (segment.severity) {
@@ -386,7 +399,7 @@ export default function Events() {
detection: detections,
significant_motion: motion,
};
}, [reviews, config?.cameras]);
}, [reviews]);
// update review items in place when a review segment ends
const reviewUpdate = useFrigateReviews();
@@ -450,15 +463,17 @@ export default function Events() {
// review summary
const { data: reviewSummary, mutate: updateSummary } = useSWR<ReviewSummary>(
[
"review/summary",
{
timezone: timezone,
cameras: reviewSearchParams["cameras"] ?? null,
labels: reviewSearchParams["labels"] ?? null,
zones: reviewSearchParams["zones"] ?? null,
},
],
timezone
? [
"review/summary",
{
timezone: timezone,
cameras: reviewCamerasParam,
labels: reviewSearchParams["labels"] ?? null,
zones: reviewSearchParams["zones"] ?? null,
},
]
: null,
{
revalidateOnFocus: true,
refreshInterval: 30000,
@@ -473,13 +488,17 @@ export default function Events() {
// recordings summary
const { data: recordingsSummary } = useSWR<RecordingsSummary>([
"recordings/summary",
{
timezone: timezone,
cameras: reviewSearchParams["cameras"] ?? null,
},
]);
const { data: recordingsSummary } = useSWR<RecordingsSummary>(
timezone
? [
"recordings/summary",
{
timezone: timezone,
cameras: reviewCamerasParam,
},
]
: null,
);
// preview videos
const previewTimes = useMemo(() => {
+1 -6
View File
@@ -706,12 +706,7 @@ export default function Settings() {
}
return Object.values(config.cameras)
.filter(
(conf) =>
conf.ui.dashboard &&
conf.enabled_in_config &&
!isReplayCamera(conf.name),
)
.filter((conf) => conf.enabled_in_config && !isReplayCamera(conf.name))
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
+43 -5
View File
@@ -8,6 +8,8 @@ import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { VolumeSlider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
@@ -1024,6 +1026,9 @@ function MotionReview({
if (!allowedCameras.includes(cam.name)) {
return false;
}
if (cam.ui?.review === false) {
return false;
}
if (selectedCams && !selectedCams.includes(cam.name)) {
return false;
}
@@ -1033,6 +1038,11 @@ function MotionReview({
return cameras.sort((a, b) => a.ui.order - b.ui.order);
}, [config, filter, allowedCameras]);
const reviewCamerasParam = useMemo(
() => reviewCameras.map((cam) => cam.name).join(","),
[reviewCameras],
);
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});
// motion data
@@ -1052,7 +1062,7 @@ function MotionReview({
before: alignedBefore,
after: alignedAfter,
scale: segmentDuration / 2,
cameras: filter?.cameras?.join(",") ?? null,
cameras: reviewCamerasParam,
},
]);
@@ -1061,7 +1071,7 @@ function MotionReview({
{
before: alignedBefore,
after: alignedAfter,
cameras: filter?.cameras?.join(",") ?? null,
cameras: reviewCamerasParam,
},
]);
@@ -1146,6 +1156,7 @@ function MotionReview({
new Set(),
);
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
const [cropToFilter, setCropToFilter] = useState(true);
// reset filter when camera changes
useEffect(() => {
@@ -1385,7 +1396,7 @@ function MotionReview({
</DialogDescription>
</DialogHeader>
<MotionRegionFilterGrid
cameraName={selectedMotionPreviewCamera.name}
camera={selectedMotionPreviewCamera}
selectedCells={pendingFilterCells}
onCellsChange={setPendingFilterCells}
/>
@@ -1440,7 +1451,12 @@ function MotionReview({
<div className="space-y-3">
<div className="space-y-0.5">
<div>{t("motionPreviews.speed")}</div>
<Label
className="cursor-pointer"
htmlFor="motionPreviewSpeed"
>
{t("motionPreviews.speed")}
</Label>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.speedDesc")}
</div>
@@ -1452,6 +1468,7 @@ function MotionReview({
}
>
<SelectTrigger
id="motionPreviewSpeed"
className="h-10 w-full"
aria-label={t("motionPreviews.speedAria")}
>
@@ -1469,7 +1486,7 @@ function MotionReview({
<div className="space-y-3">
<div className="space-y-0.5">
<div>{t("motionPreviews.dim")}</div>
<Label>{t("motionPreviews.dim")}</Label>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.dimDesc")}
</div>
@@ -1494,6 +1511,26 @@ function MotionReview({
</div>
</div>
<div className="space-y-0.5">
<div className="flex items-center justify-between gap-2">
<Label
className="cursor-pointer"
htmlFor="cropToFilter"
>
{t("motionPreviews.crop")}
</Label>
<Switch
id="cropToFilter"
checked={cropToFilter}
onCheckedChange={setCropToFilter}
aria-label={t("motionPreviews.cropAria")}
/>
</div>
<div className="text-xs text-muted-foreground">
{t("motionPreviews.cropDesc")}
</div>
</div>
{!isDesktop && (
<>
<SelectSeparator />
@@ -1549,6 +1586,7 @@ function MotionReview({
playbackRate={playbackRate}
nonMotionAlpha={dimStrength / 100}
motionFilterCells={motionFilterCells}
cropToFilter={cropToFilter}
onSeek={(timestamp) => {
onOpenRecording({
camera: selectedMotionPreviewCamera.name,
+176 -34
View File
@@ -24,6 +24,94 @@ import { FrigateConfig } from "@/types/frigateConfig";
const MOTION_HEATMAP_GRID_SIZE = 16;
const MIN_MOTION_CELL_ALPHA = 0.06;
const DEFAULT_TILE_ASPECT_RATIO = 16 / 9;
// Keep cropped tiles from collapsing into unusable slivers when the selection
// is a single row or column
const MIN_CROP_TILE_ASPECT_RATIO = 0.75;
const MAX_CROP_TILE_ASPECT_RATIO = 4;
type CropRegion = {
x: number;
y: number;
width: number;
height: number;
};
type MediaRect = {
x: number;
y: number;
width: number;
height: number;
};
function getCropRegionForCells(cells?: Set<number>): CropRegion | undefined {
if (!cells || cells.size === 0) {
return undefined;
}
let minRow = MOTION_HEATMAP_GRID_SIZE;
let maxRow = -1;
let minCol = MOTION_HEATMAP_GRID_SIZE;
let maxCol = -1;
cells.forEach((cellIndex) => {
if (
!Number.isInteger(cellIndex) ||
cellIndex < 0 ||
cellIndex >= MOTION_HEATMAP_GRID_SIZE ** 2
) {
return;
}
const row = Math.floor(cellIndex / MOTION_HEATMAP_GRID_SIZE);
const col = cellIndex % MOTION_HEATMAP_GRID_SIZE;
minRow = Math.min(minRow, row);
maxRow = Math.max(maxRow, row);
minCol = Math.min(minCol, col);
maxCol = Math.max(maxCol, col);
});
if (maxRow < 0 || maxCol < 0) {
return undefined;
}
return {
x: minCol / MOTION_HEATMAP_GRID_SIZE,
y: minRow / MOTION_HEATMAP_GRID_SIZE,
width: (maxCol - minCol + 1) / MOTION_HEATMAP_GRID_SIZE,
height: (maxRow - minRow + 1) / MOTION_HEATMAP_GRID_SIZE,
};
}
// Rendered area of object-contain media inside its container, accounting for
// letterboxing on whichever axis has slack
function getContainedMediaRect(
width: number,
height: number,
mediaDimensions: { width: number; height: number } | null,
): MediaRect {
if (
!mediaDimensions ||
mediaDimensions.width <= 0 ||
mediaDimensions.height <= 0
) {
return { x: 0, y: 0, width, height };
}
const containerAspect = width / height;
const mediaAspect = mediaDimensions.width / mediaDimensions.height;
if (mediaAspect < containerAspect) {
// Portrait / tall: constrained by height, bars on left and right
const drawWidth = height * mediaAspect;
return { x: (width - drawWidth) / 2, y: 0, width: drawWidth, height };
}
// Wide / landscape: constrained by width, bars on top and bottom
const drawHeight = width / mediaAspect;
return { x: 0, y: (height - drawHeight) / 2, width, height: drawHeight };
}
function getPreviewForMotionRange(
cameraPreviews: Preview[],
@@ -132,6 +220,8 @@ type MotionPreviewClipProps = {
fallbackFrameTimes?: number[];
motionHeatmap?: Record<string, number> | null;
nonMotionAlpha: number;
cropRegion?: CropRegion;
aspectRatio: number;
isVisible: boolean;
onSeek: (timestamp: number) => void;
};
@@ -144,6 +234,8 @@ function MotionPreviewClip({
fallbackFrameTimes,
motionHeatmap,
nonMotionAlpha,
cropRegion,
aspectRatio,
isVisible,
onSeek,
}: MotionPreviewClipProps) {
@@ -398,34 +490,12 @@ function MotionPreviewClip({
return;
}
// Calculate the actual rendered media area (object-contain letterboxing)
let drawX = 0;
let drawY = 0;
let drawWidth = width;
let drawHeight = height;
if (
mediaDimensions &&
mediaDimensions.width > 0 &&
mediaDimensions.height > 0
) {
const containerAspect = width / height;
const mediaAspect = mediaDimensions.width / mediaDimensions.height;
if (mediaAspect < containerAspect) {
// Portrait / tall: constrained by height, bars on left and right
drawHeight = height;
drawWidth = height * mediaAspect;
drawX = (width - drawWidth) / 2;
drawY = 0;
} else {
// Wide / landscape: constrained by width, bars on top and bottom
drawWidth = width;
drawHeight = width / mediaAspect;
drawX = 0;
drawY = (height - drawHeight) / 2;
}
}
const {
x: drawX,
y: drawY,
width: drawWidth,
height: drawHeight,
} = getContainedMediaRect(width, height, mediaDimensions);
const heatmapLevels = Object.values(motionHeatmap)
.map((value) => Number(value))
@@ -484,17 +554,53 @@ function MotionPreviewClip({
drawDimOverlay();
}, [drawDimOverlay]);
// Zoom the media (and its dim overlay) so the filtered region fills the tile
const mediaCropStyle = useMemo(() => {
if (!cropRegion || overlayWidth <= 0 || overlayHeight <= 0) {
return undefined;
}
const mediaRect = getContainedMediaRect(
overlayWidth,
overlayHeight,
mediaDimensions,
);
const cropWidth = cropRegion.width * mediaRect.width;
const cropHeight = cropRegion.height * mediaRect.height;
if (cropWidth <= 0 || cropHeight <= 0) {
return undefined;
}
const cropCenterX =
mediaRect.x + (cropRegion.x + cropRegion.width / 2) * mediaRect.width;
const cropCenterY =
mediaRect.y + (cropRegion.y + cropRegion.height / 2) * mediaRect.height;
const scale = Math.min(
overlayWidth / cropWidth,
overlayHeight / cropHeight,
);
const translateX = overlayWidth / 2 - scale * cropCenterX;
const translateY = overlayHeight / 2 - scale * cropCenterY;
return {
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
transformOrigin: "0 0",
};
}, [cropRegion, mediaDimensions, overlayHeight, overlayWidth]);
return (
<div
ref={overlayContainerRef}
className="relative aspect-video size-full cursor-pointer overflow-hidden rounded-lg bg-black md:rounded-2xl"
className="relative size-full cursor-pointer overflow-hidden rounded-lg bg-black md:rounded-2xl"
style={{ aspectRatio }}
onClick={() => onSeek(range.start_time)}
>
{showLoadingIndicator && (
<Skeleton className="absolute inset-0 z-10 rounded-lg md:rounded-2xl" />
)}
{preview && isVisible ? (
<>
<div className="absolute inset-0" style={mediaCropStyle}>
<video
ref={videoRef}
className="size-full bg-black object-contain"
@@ -548,9 +654,9 @@ function MotionPreviewClip({
aria-hidden="true"
/>
)}
</>
</div>
) : fallbackFrameSrc ? (
<>
<div className="absolute inset-0" style={mediaCropStyle}>
<img
src={fallbackFrameSrc}
className="size-full bg-black object-contain"
@@ -575,7 +681,7 @@ function MotionPreviewClip({
aria-hidden="true"
/>
)}
</>
</div>
) : (
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">
{t("motionPreviews.noPreview")}
@@ -605,6 +711,7 @@ type MotionPreviewsPaneProps = {
playbackRate: number;
nonMotionAlpha: number;
motionFilterCells?: Set<number>;
cropToFilter?: boolean;
onSeek: (timestamp: number) => void;
};
@@ -617,6 +724,7 @@ export default function MotionPreviewsPane({
playbackRate,
nonMotionAlpha,
motionFilterCells,
cropToFilter = true,
onSeek,
}: MotionPreviewsPaneProps) {
const { t } = useTranslation(["views/events"]);
@@ -916,6 +1024,35 @@ export default function MotionPreviewsPane({
});
}, [clipData, motionFilterCells]);
const cropRegion = useMemo(
() => (cropToFilter ? getCropRegionForCells(motionFilterCells) : undefined),
[cropToFilter, motionFilterCells],
);
// Every clip shares the same crop, so tiles stay uniform while matching the
// shape of the selected region instead of letterboxing it into 16:9
const tileAspectRatio = useMemo(() => {
if (!cropRegion) {
return DEFAULT_TILE_ASPECT_RATIO;
}
const cameraAspect =
camera.detect.width && camera.detect.height
? camera.detect.width / camera.detect.height
: DEFAULT_TILE_ASPECT_RATIO;
const croppedAspect = cameraAspect * (cropRegion.width / cropRegion.height);
if (!Number.isFinite(croppedAspect) || croppedAspect <= 0) {
return DEFAULT_TILE_ASPECT_RATIO;
}
return Math.min(
MAX_CROP_TILE_ASPECT_RATIO,
Math.max(MIN_CROP_TILE_ASPECT_RATIO, croppedAspect),
);
}, [camera.detect.height, camera.detect.width, cropRegion]);
const hasCurrentHourRanges = useMemo(
() => motionRanges.some((range) => isCurrentHour(range.end_time)),
[motionRanges],
@@ -964,6 +1101,8 @@ export default function MotionPreviewsPane({
fallbackFrameTimes={fallbackFrameTimes}
motionHeatmap={motionHeatmap}
nonMotionAlpha={nonMotionAlpha}
cropRegion={cropRegion}
aspectRatio={tileAspectRatio}
isVisible={
windowVisible &&
(visibleClips.includes(clipId) ||
@@ -972,7 +1111,10 @@ export default function MotionPreviewsPane({
onSeek={onSeek}
/>
) : (
<div className="aspect-video rounded-lg bg-black md:rounded-2xl" />
<div
className="rounded-lg bg-black md:rounded-2xl"
style={{ aspectRatio: tileAspectRatio }}
/>
)}
</div>
);
@@ -51,7 +51,12 @@ import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { getChunkedTimeDay } from "@/utils/timelineUtil";
import { MotionData, REVIEW_PADDING, ZoomLevel } from "@/types/review";
import {
MotionData,
REVIEW_PADDING,
ReviewSegment,
ZoomLevel,
} from "@/types/review";
import {
ASPECT_VERTICAL_LAYOUT,
ASPECT_WIDE_LAYOUT,
@@ -85,6 +90,7 @@ type MotionSearchViewProps = {
};
const DEFAULT_EXPORT_WINDOW_SECONDS = 60;
const NO_REVIEW_EVENTS: ReviewSegment[] = [];
export default function MotionSearchView({
config,
@@ -514,6 +520,12 @@ export default function MotionSearchView({
: null,
);
const timelineMotionEvents = useMemo(() => motionData ?? [], [motionData]);
const timelineNoRecordings = useMemo(
() => noRecordings ?? [],
[noRecordings],
);
const recordingParams = useMemo(
() => ({
before: currentTimeRange.before,
@@ -1054,11 +1066,11 @@ export default function MotionSearchView({
showHandlebar={true}
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={[]}
motion_events={motionData ?? []}
noRecordingRanges={noRecordings ?? []}
events={NO_REVIEW_EVENTS}
motion_events={timelineMotionEvents}
noRecordingRanges={timelineNoRecordings}
contentRef={contentRef}
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
onHandlebarDraggingChange={setScrubbing}
showExportHandles={
(exportMode === "timeline" || exportMode === "timeline_multi") &&
Boolean(exportRange)
@@ -1489,7 +1501,7 @@ export default function MotionSearchView({
isDesktop
? mainCameraAspect === "tall"
? "mr-2 h-full min-h-0 min-w-0 flex-1 items-center"
: "mr-2 h-full min-h-0 min-w-0 flex-1"
: "mx-2 h-full min-h-0 min-w-0 flex-1"
: mainCameraAspect === "tall"
? "flex-1 portrait:h-[40dvh] portrait:max-h-[40dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto portrait:items-center portrait:justify-center"
: "flex-1 portrait:max-h-[40dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto landscape:items-center landscape:justify-center",
+1 -1
View File
@@ -1199,7 +1199,7 @@ function Timeline({
motion_events={motionData ?? []}
noRecordingRanges={noRecordings ?? []}
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
onHandlebarDraggingChange={setScrubbing}
isZooming={isZooming}
zoomDirection={zoomDirection}
onZoomChange={handleZoomChange}
+33 -6
View File
@@ -540,6 +540,36 @@ export default function GeneralMetrics({
return Object.keys(series).length > 0 ? Object.values(series) : undefined;
}, [statsHistory]);
// Number of cards the hardware grid renders. Which ones appear depends on
// the vendor, so the column count follows the count rather than assuming a
// fixed set is present.
const hardwareCardCount = useMemo(() => {
if (!statsHistory[0]?.gpu_usages) {
return 0;
}
const hasNpu = statsHistory[0].npu_usages != undefined;
return (
1 + // gpu usage always renders alongside gpu_usages
(gpuMemSeries ? 1 : 0) +
(gpuEncSeries?.length ? 1 : 0) +
(gpuComputeSeries?.length ? 1 : 0) +
(gpuDecSeries?.length ? 1 : 0) +
(gpuTempSeries?.length ? 1 : 0) +
(hasNpu ? 1 : 0) +
(hasNpu && npuTempSeries?.length ? 1 : 0)
);
}, [
statsHistory,
gpuMemSeries,
gpuEncSeries,
gpuComputeSeries,
gpuDecSeries,
gpuTempSeries,
npuTempSeries,
]);
// other processes stats
const hardwareType = useMemo(() => {
@@ -763,12 +793,9 @@ export default function GeneralMetrics({
<div
className={cn(
"mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2",
gpuTempSeries?.length && "md:grid-cols-3",
(gpuEncSeries?.length || gpuComputeSeries?.length) &&
"xl:grid-cols-4",
(gpuEncSeries?.length || gpuComputeSeries?.length) &&
gpuTempSeries?.length &&
"3xl:grid-cols-5",
hardwareCardCount >= 3 && "lg:grid-cols-3",
hardwareCardCount >= 4 && "xl:grid-cols-4",
hardwareCardCount >= 5 && "3xl:grid-cols-5",
)}
>
{statsHistory[0]?.gpu_usages && (
+3
View File
@@ -37,6 +37,9 @@ export default defineConfig({
},
},
},
esbuild: {
keepNames: true,
},
build: {
rollupOptions: {
input: {