mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-20 18:59:01 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
220d09c068 |
@@ -22,8 +22,3 @@ core
|
||||
!/web/**/*.ts
|
||||
.idea/*
|
||||
.ipynb_checkpoints
|
||||
|
||||
# Auto-generated Docker Compose Generator config files
|
||||
docs/src/components/DockerComposeGenerator/config/devices.ts
|
||||
docs/src/components/DockerComposeGenerator/config/hardware.ts
|
||||
docs/src/components/DockerComposeGenerator/config/ports.ts
|
||||
|
||||
+2
-7
@@ -10,14 +10,11 @@ If you've found a bug and want to fix it, go for it. Link to the relevant issue
|
||||
|
||||
### New features
|
||||
|
||||
A pull request is more than just code — it's a request for the maintainers to review, integrate, and support the change long-term. We're selective about what we take on, and prioritize changes that align with the project's direction and can be responsibly maintained in the long term.
|
||||
|
||||
**Large or highly-requested features** raise the bar even higher. Popularity signals demand, but it doesn't pre-approve any particular implementation. The bigger the change, the higher the long-term cost, and the more important it is that we're aligned on scope and approach before any code is written. A large PR that lands without prior discussion is unlikely to be merged as-is, no matter how well it's implemented.
|
||||
|
||||
Before writing code for a new feature:
|
||||
Every new feature adds scope that the maintainers must test, maintain, and support long-term. Before writing code for a new feature:
|
||||
|
||||
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Feature requests tagged with "planned" are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
|
||||
2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it.
|
||||
3. **Be open to "no".** We try to be thoughtful about what we take on, and sometimes that means saying no to good code if the feature isn't the right fit for the project. These calls are sometimes subjective, and we won't always get them right. We're happy to discuss and reconsider.
|
||||
|
||||
## AI usage policy
|
||||
|
||||
@@ -42,8 +39,6 @@ We're not trying to gatekeep how you write code. Use whatever tools make you pro
|
||||
|
||||
Some honest context: when we review a PR, we're not just evaluating whether the code works today. We're evaluating whether we can maintain it, debug it, and extend it long-term — often without the original author's involvement. Code that the author doesn't deeply understand is code that nobody understands, and that's a liability.
|
||||
|
||||
One more thing worth saying directly: most maintainers already have access to the same AI tools you do. A PR that's entirely AI-generated — where the author can't explain the design, debug issues independently, or engage substantively in design discussions — doesn't offer something we couldn't produce ourselves. What makes a contribution genuinely valuable is the human judgment and domain understanding behind it, as well as the engagement during review that shapes it into something we can confidently take on long-term.
|
||||
|
||||
## Pull request guidelines
|
||||
|
||||
### Before submitting
|
||||
|
||||
@@ -13,7 +13,7 @@ ARG ROCM
|
||||
|
||||
RUN apt update -qq && \
|
||||
apt install -y wget gpg && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2.3/ubuntu/jammy/amdgpu-install_7.2.3.70203-1_all.deb && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2/ubuntu/jammy/amdgpu-install_7.2.70200-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
@@ -78,10 +78,6 @@ ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1
|
||||
ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1
|
||||
ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1
|
||||
ENV MIGRAPHX_ENABLE_HIPRTC_WORKAROUNDS=1
|
||||
ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx
|
||||
ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx
|
||||
ENV AMD_COMGR_CACHE=1
|
||||
ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx
|
||||
|
||||
COPY --from=rocm-dist / /
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.3-1/onnxruntime_migraphx-1.24.4-cp311-cp311-linux_x86_64.whl
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl
|
||||
@@ -1,5 +1,5 @@
|
||||
variable "ROCM" {
|
||||
default = "7.2.3"
|
||||
default = "7.2.0"
|
||||
}
|
||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||
default = ""
|
||||
|
||||
@@ -19,7 +19,7 @@ Face recognition requires a one-time internet connection to download detection a
|
||||
|
||||
### Face Detection
|
||||
|
||||
When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/index.md#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
|
||||
When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
|
||||
|
||||
When running a default COCO model or another model that does not include `face` as a detectable label, face detection will run via CV2 using a lightweight DNN model that runs on the CPU. In this case, you should _not_ define `face` in your list of objects to track.
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ Cloud Generative AI providers require an active internet connection to send imag
|
||||
|
||||
### Ollama Cloud
|
||||
|
||||
Ollama also supports [cloud models](https://ollama.com/cloud), where model inference is performed in the cloud. You can connect directly to Ollama Cloud by setting `base_url` to `https://ollama.com` and providing an API key. Alternatively, you can run Ollama locally and use a cloud model name so your local instance forwards requests to the cloud. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
|
||||
Ollama also supports [cloud models](https://ollama.com/cloud), where your local Ollama instance handles requests from Frigate, but model inference is performed in the cloud. Set up Ollama locally, sign in with your Ollama account, and specify the cloud model name in your Frigate config. For more details, see the Ollama cloud model [docs](https://docs.ollama.com/cloud).
|
||||
|
||||
#### Configuration
|
||||
|
||||
@@ -210,8 +210,7 @@ Ollama also supports [cloud models](https://ollama.com/cloud), where model infer
|
||||
|
||||
1. Navigate to <NavPath path="Settings > Enrichments > Generative AI" />.
|
||||
- Set **Provider** to `ollama`
|
||||
- Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`) or `https://ollama.com` for direct cloud inference
|
||||
- Set **API key** if required by your endpoint (e.g., when using `https://ollama.com`)
|
||||
- Set **Base URL** to your local Ollama address (e.g., `http://localhost:11434`)
|
||||
- Set **Model** to the cloud model name
|
||||
|
||||
</TabItem>
|
||||
@@ -224,16 +223,6 @@ genai:
|
||||
model: cloud-model-name
|
||||
```
|
||||
|
||||
or when using Ollama Cloud directly
|
||||
|
||||
```yaml
|
||||
genai:
|
||||
provider: ollama
|
||||
base_url: https://ollama.com
|
||||
model: cloud-model-name
|
||||
api_key: your-api-key
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
|
||||
@@ -136,32 +136,90 @@ ffmpeg:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Configuring Intel GPU Stats
|
||||
### Configuring Intel GPU Stats in Docker
|
||||
|
||||
Frigate reads Intel GPU utilization directly from the kernel's per-client DRM usage counters exposed at `/proc/<pid>/fdinfo/<fd>`. This requires:
|
||||
Additional configuration is needed for the Docker container to be able to access the `intel_gpu_top` command for GPU stats. There are two options:
|
||||
|
||||
- Linux kernel **5.19 or newer** for the `i915` driver, or any release of the `xe` driver.
|
||||
- Frigate running with permission to read other processes' fdinfo. Running as root inside the container (the default) satisfies this; non-root setups may need `CAP_SYS_PTRACE`.
|
||||
1. Run the container as privileged.
|
||||
2. Add the `CAP_PERFMON` capability (note: you might need to set the `perf_event_paranoid` low enough to allow access to the performance event system.)
|
||||
|
||||
No `intel_gpu_top` binary, `CAP_PERFMON`, privileged mode, or `perf_event_paranoid` tuning is required.
|
||||
#### Run as privileged
|
||||
|
||||
#### Stats for SR-IOV or specific devices
|
||||
This method works, but it gives more permissions to the container than are actually needed.
|
||||
|
||||
If the host has more than one Intel GPU (e.g. an iGPU plus a discrete GPU, or SR-IOV virtual functions), pin stats collection to a specific device by setting `intel_gpu_device` to either its PCI bus address or a DRM card/render-node path:
|
||||
##### Docker Compose - Privileged
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
# highlight-next-line
|
||||
privileged: true
|
||||
```
|
||||
|
||||
##### Docker Run CLI - Privileged
|
||||
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
--privileged \
|
||||
ghcr.io/blakeblackshear/frigate:stable
|
||||
```
|
||||
|
||||
#### CAP_PERFMON
|
||||
|
||||
Only recent versions of Docker support the `CAP_PERFMON` capability. You can test to see if yours supports it by running: `docker run --cap-add=CAP_PERFMON hello-world`
|
||||
|
||||
##### Docker Compose - CAP_PERFMON
|
||||
|
||||
```yaml {5,6}
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
cap_add:
|
||||
- CAP_PERFMON
|
||||
```
|
||||
|
||||
##### Docker Run CLI - CAP_PERFMON
|
||||
|
||||
```bash {4}
|
||||
docker run -d \
|
||||
--name frigate \
|
||||
...
|
||||
--cap-add=CAP_PERFMON \
|
||||
ghcr.io/blakeblackshear/frigate:stable
|
||||
```
|
||||
|
||||
#### perf_event_paranoid
|
||||
|
||||
_Note: This setting must be changed for the entire system._
|
||||
|
||||
For more information on the various values across different distributions, see https://askubuntu.com/questions/1400874/what-does-perf-paranoia-level-four-do.
|
||||
|
||||
Depending on your OS and kernel configuration, you may need to change the `/proc/sys/kernel/perf_event_paranoid` kernel tunable. You can test the change by running `sudo sh -c 'echo 2 >/proc/sys/kernel/perf_event_paranoid'` which will persist until a reboot. Make it permanent by running `sudo sh -c 'echo kernel.perf_event_paranoid=2 >> /etc/sysctl.d/local.conf'`
|
||||
|
||||
#### Stats for SR-IOV or other devices
|
||||
|
||||
When using virtualized GPUs via SR-IOV, you need to specify the device path to use to gather stats from `intel_gpu_top`. This example may work for some systems using SR-IOV:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
stats:
|
||||
intel_gpu_device: "0000:00:02.0"
|
||||
intel_gpu_device: "sriov"
|
||||
```
|
||||
|
||||
For other virtualized GPUs, try specifying the direct path to the device instead:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
stats:
|
||||
intel_gpu_device: "/dev/dri/card1"
|
||||
intel_gpu_device: "drm:/dev/dri/card0"
|
||||
```
|
||||
|
||||
When passing a device path, make sure the device is also passed through to the container.
|
||||
If you are passing in a device path, make sure you've passed the device through to the container.
|
||||
|
||||
## AMD-based CPUs
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ This does not affect using hardware for accelerating other tasks such as [semant
|
||||
|
||||
# Officially Supported Detectors
|
||||
|
||||
Frigate provides a number of builtin detector types. By default, Frigate will use a single OpenVINO detector running on the CPU. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
|
||||
Frigate provides a number of builtin detector types. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras.
|
||||
|
||||
## Edge TPU Detector
|
||||
|
||||
@@ -494,7 +494,7 @@ detectors:
|
||||
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
|
||||
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
|
||||
| [YOLOX](#yolox) | ✅ | ? | |
|
||||
| [D-FINE / DEIMv2](#d-fine--deimv2) | ❌ | ❌ | |
|
||||
| [D-FINE](#d-fine) | ❌ | ❌ | |
|
||||
|
||||
#### SSDLite MobileNet v2
|
||||
|
||||
@@ -710,13 +710,13 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
#### D-FINE / DEIMv2
|
||||
#### D-FINE
|
||||
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
|
||||
|
||||
:::warning
|
||||
|
||||
Currently D-FINE / DEIMv2 models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model
|
||||
Currently D-FINE models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model
|
||||
|
||||
:::
|
||||
|
||||
@@ -766,31 +766,6 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>DEIMv2 Setup & Config</summary>
|
||||
|
||||
After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
ov:
|
||||
type: openvino
|
||||
device: CPU
|
||||
|
||||
model:
|
||||
model_type: dfine
|
||||
width: 640
|
||||
height: 640
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/model_cache/deimv2_hgnetv2_n.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
```
|
||||
|
||||
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
||||
|
||||
</details>
|
||||
|
||||
## Apple Silicon detector
|
||||
|
||||
The NPU in Apple Silicon can't be accessed from within a container, so the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) must first be setup. It is recommended to use the Frigate docker image with `-standard-arm64` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-standard-arm64`.
|
||||
@@ -972,7 +947,7 @@ The AMD GPU kernel is known problematic especially when converting models to mxr
|
||||
|
||||
See [ONNX supported models](#supported-models) for supported models, there are some caveats:
|
||||
|
||||
- D-FINE / DEIMv2 models are not supported
|
||||
- D-FINE models are not supported
|
||||
- YOLO-NAS models are known to not run well on integrated GPUs
|
||||
|
||||
## ONNX
|
||||
@@ -1022,13 +997,13 @@ detectors:
|
||||
|
||||
### ONNX Supported Models
|
||||
|
||||
| Model | Nvidia GPU | AMD GPU | Notes |
|
||||
| ------------------------------------ | ---------- | ------- | --------------------------------------------------- |
|
||||
| [YOLOv9](#yolo-v3-v4-v7-v9-2) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [RF-DETR](#rf-detr) | ✅ | ⚠️ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [YOLO-NAS](#yolo-nas-1) | ⚠️ | ⚠️ | Not supported by CUDA Graphs |
|
||||
| [YOLOX](#yolox-1) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [D-FINE / DEIMv2](#d-fine--deimv2-1) | ⚠️ | ❌ | Not supported by CUDA Graphs |
|
||||
| Model | Nvidia GPU | AMD GPU | Notes |
|
||||
| ----------------------------- | ---------- | ------- | --------------------------------------------------- |
|
||||
| [YOLOv9](#yolo-v3-v4-v7-v9-2) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [RF-DETR](#rf-detr) | ✅ | ❌ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [YOLO-NAS](#yolo-nas-1) | ⚠️ | ⚠️ | Not supported by CUDA Graphs |
|
||||
| [YOLOX](#yolox-1) | ✅ | ✅ | Supports CUDA Graphs for optimal Nvidia performance |
|
||||
| [D-FINE](#d-fine) | ⚠️ | ❌ | Not supported by CUDA Graphs |
|
||||
|
||||
There is no default model provided, the following formats are supported:
|
||||
|
||||
@@ -1240,9 +1215,9 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
#### D-FINE / DEIMv2
|
||||
#### D-FINE
|
||||
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
|
||||
|
||||
<details>
|
||||
<summary>D-FINE Setup & Config</summary>
|
||||
@@ -1287,28 +1262,6 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>DEIMv2 Setup & Config</summary>
|
||||
|
||||
After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
onnx:
|
||||
type: onnx
|
||||
|
||||
model:
|
||||
model_type: dfine
|
||||
width: 640
|
||||
height: 640
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/model_cache/deimv2_hgnetv2_n.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
||||
|
||||
## CPU Detector (not recommended)
|
||||
@@ -1452,7 +1405,7 @@ MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the
|
||||
|
||||
#### YOLO-NAS
|
||||
|
||||
The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).
|
||||
The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage).
|
||||
|
||||
**Note:** The default model for the MemryX detector is YOLO-NAS 320x320.
|
||||
|
||||
@@ -1506,7 +1459,7 @@ model:
|
||||
|
||||
#### YOLOv9
|
||||
|
||||
The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).
|
||||
The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage).
|
||||
|
||||
##### Configuration
|
||||
|
||||
@@ -1648,39 +1601,19 @@ model:
|
||||
|
||||
#### Using a Custom Model
|
||||
|
||||
To use your own custom model, first compile it into a [.dfp](https://developer.memryx.com/2p1/specs/files.html#dataflow-program) file, which is the format used by MemryX.
|
||||
To use your own model:
|
||||
|
||||
#### Compile the Model
|
||||
1. Package your compiled model into a `.zip` file.
|
||||
|
||||
Custom models must be compiled using **MemryX SDK 2.1**.
|
||||
2. The `.zip` must contain the compiled `.dfp` file.
|
||||
|
||||
Before compiling your model, install the MemryX Neural Compiler tools from the
|
||||
[Install Tools](https://developer.memryx.com/2p1/get_started/install_tools.html) page on the **host**.
|
||||
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`.
|
||||
|
||||
> **Note:** It is recommended to compile the model on the host machine, or on another separate machine, rather than inside the Frigate Docker container. Installing the compiler inside Docker may conflict with container packages. It is recommended to create a Python virtual environment and install the compiler there.
|
||||
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config.
|
||||
|
||||
Once the SDK 2.1 environment is set up, follow the
|
||||
[MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) documentation to compile your model.
|
||||
5. Update the `labelmap_path` to match your custom model's labels.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
mx_nc -m yolonas.onnx -c 4 --autocrop -v --dfp_fname yolonas.dfp
|
||||
```
|
||||
|
||||
For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/2p1/tutorials/tutorials.html).
|
||||
|
||||
#### Package the Compiled Model
|
||||
|
||||
1. Package your compiled model into a `.zip` file.
|
||||
|
||||
2. The `.zip` file must contain the compiled `.dfp` file.
|
||||
|
||||
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`.
|
||||
|
||||
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config.
|
||||
|
||||
5. Update `labelmap_path` to match your custom model's labels.
|
||||
For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/tutorials/tutorials.html).
|
||||
|
||||
```yaml
|
||||
# The detector automatically selects the default model if nothing is provided in the config.
|
||||
@@ -2341,49 +2274,6 @@ COPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL
|
||||
EOF
|
||||
```
|
||||
|
||||
### Downloading DEIMv2 Model
|
||||
|
||||
[DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families:
|
||||
|
||||
- **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n`
|
||||
- **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x`
|
||||
|
||||
Set `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`).
|
||||
|
||||
```sh
|
||||
docker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF'
|
||||
FROM python:3.11-slim AS build
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
|
||||
WORKDIR /deimv2
|
||||
RUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git .
|
||||
# Install CPU-only PyTorch first to avoid pulling CUDA variant
|
||||
RUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||
RUN uv pip install --no-cache --system -r requirements.txt
|
||||
RUN uv pip install --no-cache --system onnx safetensors huggingface_hub
|
||||
RUN mkdir -p output
|
||||
ARG BACKBONE
|
||||
ARG MODEL_SIZE
|
||||
# Download from Hugging Face and convert safetensors to pth
|
||||
RUN python3 -c "\
|
||||
from huggingface_hub import hf_hub_download; \
|
||||
from safetensors.torch import load_file; \
|
||||
import torch; \
|
||||
backbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \
|
||||
size = '${MODEL_SIZE}'.upper(); \
|
||||
st = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \
|
||||
torch.save({'model': st}, 'output/deimv2.pth')"
|
||||
RUN sed -i "s/data = torch.rand(2/data = torch.rand(1/" tools/deployment/export_onnx.py
|
||||
# HuggingFace safetensors omits frozen constants that the model constructor initializes
|
||||
RUN sed -i "s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/" tools/deployment/export_onnx.py
|
||||
RUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth
|
||||
FROM scratch
|
||||
ARG BACKBONE
|
||||
ARG MODEL_SIZE
|
||||
COPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx
|
||||
EOF
|
||||
```
|
||||
|
||||
### Downloading RF-DETR Model
|
||||
|
||||
RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size.
|
||||
|
||||
@@ -195,7 +195,7 @@ Pre and post capture footage is included in the **recording timeline**, visible
|
||||
|
||||
## Will Frigate delete old recordings if my storage runs out?
|
||||
|
||||
If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible.
|
||||
As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted.
|
||||
|
||||
## Configuring Recording Retention
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ Enabling arbitrary exec sources allows execution of arbitrary commands through g
|
||||
|
||||
## Advanced Restream Configurations
|
||||
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-exec) source in go2rtc can be used for custom ffmpeg commands and other applications. An example is below:
|
||||
The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.13#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below:
|
||||
|
||||
:::warning
|
||||
|
||||
@@ -244,11 +244,16 @@ The `exec:`, `echo:`, and `expr:` sources are disabled by default for security.
|
||||
|
||||
:::
|
||||
|
||||
NOTE: RTSP output will need to be passed with two curly braces `{{output}}`, whereas pipe output must be passed without curly braces.
|
||||
:::warning
|
||||
|
||||
The `exec:`, `echo:`, and `expr:` sources are disabled by default for security. You must set `GO2RTC_ALLOW_ARBITRARY_EXEC=true` to use them. See [Security: Restricted Stream Sources](#security-restricted-stream-sources) for more information.
|
||||
|
||||
:::
|
||||
|
||||
NOTE: The output will need to be passed with two curly braces `{{output}}`
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {{output}}
|
||||
stream2: exec:rpicam-vid -t 0 --libav-format h264 -o -
|
||||
```
|
||||
|
||||
@@ -223,11 +223,10 @@ Apple Silicon can not run within a container, so a ZMQ proxy is utilized to comm
|
||||
|
||||
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs.
|
||||
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
|
||||
| -------------- | --------------------------- | ------------------------- | ---------------------- |
|
||||
| AMD 780M | t-320: ~ 14 ms s-320: 20 ms | 320: ~ 25 ms 640: ~ 50 ms | |
|
||||
| AMD 8700G | | 320: ~ 20 ms 640: ~ 40 ms | |
|
||||
| AMD 9060XT 16G | t-320: ~ 4 ms s-320: 5 ms | 320: ~ 6 ms | Nano-320: ~ 90 ms |
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time |
|
||||
| --------- | --------------------------- | ------------------------- |
|
||||
| AMD 780M | t-320: ~ 14 ms s-320: 20 ms | 320: ~ 25 ms 640: ~ 50 ms |
|
||||
| AMD 8700G | | 320: ~ 20 ms 640: ~ 40 ms |
|
||||
|
||||
## Community Supported Detectors
|
||||
|
||||
|
||||
@@ -4,15 +4,12 @@ title: Installation
|
||||
---
|
||||
|
||||
import ShmCalculator from '@site/src/components/ShmCalculator'
|
||||
import DockerComposeGenerator from '@site/src/components/DockerComposeGenerator'
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
|
||||
|
||||
:::tip
|
||||
|
||||
If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started.md#configuring-frigate) to configure Frigate.
|
||||
If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started#configuring-frigate) to configure Frigate.
|
||||
|
||||
:::
|
||||
|
||||
@@ -289,7 +286,7 @@ The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVM
|
||||
|
||||
#### Installation
|
||||
|
||||
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/2p1/get_started/install_hardware.html).
|
||||
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/get_started/hardware_setup.html).
|
||||
|
||||
Then follow these steps for installing the correct driver/runtime configuration:
|
||||
|
||||
@@ -298,12 +295,6 @@ Then follow these steps for installing the correct driver/runtime configuration:
|
||||
3. Run the script with `./user_installation.sh`
|
||||
4. **Restart your computer** to complete driver installation.
|
||||
|
||||
:::warning
|
||||
|
||||
For manual setup, use **MemryX SDK 2.1** only. Other SDK versions are not supported for this setup. See the [SDK 2.1 documentation](https://developer.memryx.com/2p1/index.html)
|
||||
|
||||
:::
|
||||
|
||||
#### Setup
|
||||
|
||||
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||
@@ -477,16 +468,6 @@ Finally, configure [hardware object detection](/configuration/object_detectors#a
|
||||
|
||||
Running through Docker with Docker Compose is the recommended install method.
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="domestic" label="Docker Compose Generator" default>
|
||||
|
||||
Generate a Frigate Docker Compose configuration based on your hardware and requirements.
|
||||
|
||||
<DockerComposeGenerator/>
|
||||
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="original" label="Example Docker Compose File">
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
@@ -520,10 +501,6 @@ services:
|
||||
environment:
|
||||
FRIGATE_RTSP_PASSWORD: "password"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
**Docker CLI**
|
||||
|
||||
If you can't use Docker Compose, you can run the container with something similar to this:
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ cameras:
|
||||
|
||||
### Step 4: Configure detectors
|
||||
|
||||
By default, Frigate will use a single OpenVINO detector running on the CPU.
|
||||
By default, Frigate will use a single CPU detector.
|
||||
|
||||
In many cases, the integrated graphics on Intel CPUs provides sufficient performance for typical Frigate setups. If you have an Intel processor, you can follow the configuration below.
|
||||
|
||||
|
||||
Generated
+1
-8
@@ -14,11 +14,9 @@
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@inkeep/docusaurus": "^2.0.16",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"clsx": "^2.1.1",
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.3.1",
|
||||
@@ -5749,11 +5747,6 @@
|
||||
"@types/istanbul-lib-report": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://mirrors.tencent.com/npm/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -12890,7 +12883,7 @@
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://mirrors.tencent.com/npm/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+2
-5
@@ -3,10 +3,9 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:config": "node scripts/build-config.mjs",
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "npm run build:config && npm run regen-docs && docusaurus start --host 0.0.0.0",
|
||||
"build": "npm run build:config && npm run regen-docs && docusaurus build",
|
||||
"start": "npm run regen-docs && docusaurus start --host 0.0.0.0",
|
||||
"build": "npm run regen-docs && docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
@@ -24,11 +23,9 @@
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@inkeep/docusaurus": "^2.0.16",
|
||||
"@mdx-js/react": "^3.1.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"clsx": "^2.1.1",
|
||||
"docusaurus-plugin-openapi-docs": "^4.5.1",
|
||||
"docusaurus-theme-openapi-docs": "^4.5.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Build script: reads config.yaml and generates TypeScript files
|
||||
* for the Docker Compose Generator.
|
||||
*
|
||||
* Usage: node scripts/build-config.mjs
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_DIR = path.resolve(__dirname, "../src/components/DockerComposeGenerator/config");
|
||||
const YAML_PATH = path.join(CONFIG_DIR, "config.yaml");
|
||||
|
||||
// Read & parse YAML
|
||||
const raw = fs.readFileSync(YAML_PATH, "utf8");
|
||||
const config = yaml.load(raw);
|
||||
|
||||
if (!config.devices || !config.hardware || !config.ports) {
|
||||
console.error("config.yaml must contain 'devices', 'hardware', and 'ports' sections.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a .ts file from a section of the YAML config.
|
||||
*/
|
||||
function generateTsFile(sectionName, items, typeName, varName, mapVarName, yamlFilename) {
|
||||
const jsonItems = JSON.stringify(items, null, 2);
|
||||
// Indent JSON to fit inside the array literal
|
||||
const indented = jsonItems
|
||||
.split("\n")
|
||||
.map((line, i) => (i === 0 ? line : " " + line))
|
||||
.join("\n");
|
||||
|
||||
const content = `/**
|
||||
* AUTO-GENERATED FILE — do not edit directly.
|
||||
* Source: ${yamlFilename}
|
||||
* To update, edit the YAML file and run: npm run build:config
|
||||
*/
|
||||
|
||||
import type { ${typeName} } from "./types";
|
||||
|
||||
export const ${varName}: ${typeName}[] = ${indented};
|
||||
|
||||
/** Lookup map for quick access by ID */
|
||||
export const ${mapVarName}: Map<string, ${typeName}> = new Map(${varName}.map((item) => [item.id, item]));
|
||||
`;
|
||||
|
||||
const outPath = path.join(CONFIG_DIR, `${sectionName}.ts`);
|
||||
fs.writeFileSync(outPath, content, "utf8");
|
||||
console.log(` ✓ Generated ${sectionName}.ts (${items.length} items)`);
|
||||
}
|
||||
|
||||
console.log("Building config from config.yaml...");
|
||||
|
||||
generateTsFile("devices", config.devices, "DeviceConfig", "devices", "deviceMap", "config.yaml");
|
||||
generateTsFile("hardware", config.hardware, "HardwareOption", "hardwareOptions", "hardwareMap", "config.yaml");
|
||||
generateTsFile("ports", config.ports, "PortConfig", "ports", "portMap", "config.yaml");
|
||||
|
||||
console.log("Done!");
|
||||
@@ -1,108 +0,0 @@
|
||||
import React from "react";
|
||||
import Admonition from "@theme/Admonition";
|
||||
import DeviceSelector from "./components/DeviceSelector";
|
||||
import HardwareOptions from "./components/HardwareOptions";
|
||||
import PortConfigSection from "./components/PortConfig";
|
||||
import StoragePaths from "./components/StoragePaths";
|
||||
import NvidiaGpuConfig from "./components/NvidiaGpuConfig";
|
||||
import OtherOptions from "./components/OtherOptions";
|
||||
import GeneratedOutput from "./components/GeneratedOutput";
|
||||
import { useConfigGenerator } from "./hooks/useConfigGenerator";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
/**
|
||||
* Simple markdown-link-to-React renderer for help text.
|
||||
* Only supports [text](url) syntax — no nested brackets.
|
||||
*/
|
||||
function renderHelpText(text: string): React.ReactNode {
|
||||
const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
|
||||
return parts.map((part, i) => {
|
||||
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
if (match) {
|
||||
return (
|
||||
<a key={i} href={match[2]}>
|
||||
{match[1]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <React.Fragment key={i}>{part}</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
export default function DockerComposeGenerator() {
|
||||
const {
|
||||
deviceId, device, hardwareEnabled,
|
||||
portEnabled,
|
||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
||||
hasAnyHardware, generatedYaml,
|
||||
selectDevice, toggleHardware, togglePort,
|
||||
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
||||
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
||||
setRtspPassword, setTimezone, isHardwareDisabled,
|
||||
} = useConfigGenerator();
|
||||
|
||||
return (
|
||||
<div className={styles.generator}>
|
||||
<div className={styles.card}>
|
||||
<DeviceSelector selectedId={deviceId} onSelect={selectDevice} />
|
||||
|
||||
{device.helpText && (
|
||||
<Admonition type={device.helpType || "info"}>
|
||||
{renderHelpText(device.helpText)}
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
{device.needsNvidiaConfig && (
|
||||
<NvidiaGpuConfig
|
||||
gpuCount={nvidiaGpuCount}
|
||||
gpuDeviceId={nvidiaGpuDeviceId}
|
||||
gpuDeviceIdError={gpuDeviceIdError}
|
||||
onGpuCountChange={handleNvidiaGpuCountChange}
|
||||
onGpuDeviceIdChange={handleNvidiaGpuDeviceIdChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HardwareOptions
|
||||
deviceId={deviceId}
|
||||
hardwareEnabled={hardwareEnabled}
|
||||
onToggle={toggleHardware}
|
||||
isDisabled={isHardwareDisabled}
|
||||
/>
|
||||
|
||||
<StoragePaths
|
||||
configPath={configPath}
|
||||
mediaPath={mediaPath}
|
||||
configPathError={configPathError}
|
||||
mediaPathError={mediaPathError}
|
||||
onConfigPathChange={handleConfigPathChange}
|
||||
onMediaPathChange={handleMediaPathChange}
|
||||
/>
|
||||
|
||||
<PortConfigSection
|
||||
portEnabled={portEnabled}
|
||||
onTogglePort={togglePort}
|
||||
/>
|
||||
|
||||
<OtherOptions
|
||||
rtspPassword={rtspPassword}
|
||||
timezone={timezone}
|
||||
shmSize={shmSize}
|
||||
shmSizeError={shmSizeError}
|
||||
onRtspPasswordChange={setRtspPassword}
|
||||
onTimezoneChange={setTimezone}
|
||||
onShmSizeChange={handleShmSizeChange}
|
||||
/>
|
||||
|
||||
<GeneratedOutput
|
||||
yaml={generatedYaml}
|
||||
configPath={configPath}
|
||||
mediaPath={mediaPath}
|
||||
hasAnyHardware={hasAnyHardware}
|
||||
deviceId={deviceId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import React from "react";
|
||||
import { useColorMode } from "@docusaurus/theme-common";
|
||||
import { devices } from "../config";
|
||||
import type { DeviceConfig } from "../config";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the icon type from the icon string:
|
||||
* - Starts with "<svg" → inline SVG
|
||||
* - Starts with "/" or "http" → image URL/path
|
||||
* - Otherwise → emoji text
|
||||
*/
|
||||
function getIconType(icon: string): "svg" | "image" | "emoji" {
|
||||
const trimmed = icon.trim();
|
||||
if (trimmed.startsWith("<svg")) return "svg";
|
||||
if (trimmed.startsWith("/") || trimmed.startsWith("http://") || trimmed.startsWith("https://")) return "image";
|
||||
return "emoji";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the style object contains background-* properties,
|
||||
* indicating the image should be rendered as a CSS background-image
|
||||
* rather than an <img> tag.
|
||||
*/
|
||||
function hasBackgroundProps(style: React.CSSProperties | undefined): boolean {
|
||||
if (!style) return false;
|
||||
return Object.keys(style).some((key) => {
|
||||
const k = key.toLowerCase().replace(/-/g, "");
|
||||
return k === "backgroundsize" || k === "backgroundposition" || k === "backgroundrepeat" || k === "backgroundimage";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a style object to CSS custom properties (e.g. { width: "24px" } → { "--svg-width": "24px" })
|
||||
* so they can be consumed by CSS rules targeting child elements like <svg>.
|
||||
*/
|
||||
function toCssVars(style: React.CSSProperties | undefined, prefix: string): React.CSSProperties {
|
||||
if (!style) return {};
|
||||
const vars: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(style)) {
|
||||
const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
||||
vars[`--${prefix}-${cssKey}`] = value;
|
||||
}
|
||||
return vars as React.CSSProperties;
|
||||
}
|
||||
|
||||
function DeviceIcon({ device }: { device: DeviceConfig }) {
|
||||
const { isDarkTheme } = useColorMode();
|
||||
const iconStr = isDarkTheme && device.iconDark ? device.iconDark : device.icon;
|
||||
const iconStyle = (isDarkTheme && device.iconDarkStyle
|
||||
? device.iconDarkStyle
|
||||
: device.iconStyle) as React.CSSProperties | undefined;
|
||||
const svgStyle = (isDarkTheme && device.svgDarkStyle
|
||||
? device.svgDarkStyle
|
||||
: device.svgStyle) as React.CSSProperties | undefined;
|
||||
|
||||
const iconType = getIconType(iconStr);
|
||||
|
||||
if (iconType === "svg") {
|
||||
return (
|
||||
<div
|
||||
className={styles.deviceIconSvg}
|
||||
style={{ ...iconStyle, ...toCssVars(svgStyle, "svg") }}
|
||||
dangerouslySetInnerHTML={{ __html: iconStr }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (iconType === "image") {
|
||||
// When iconStyle contains background-* properties, render as background-image
|
||||
// on the container div instead of an <img> tag, enabling background-size/position control.
|
||||
if (hasBackgroundProps(iconStyle)) {
|
||||
return (
|
||||
<div
|
||||
className={styles.deviceIconImage}
|
||||
style={{
|
||||
backgroundImage: `url(${iconStr})`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: "contain",
|
||||
...iconStyle,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.deviceIconImage}>
|
||||
<img src={iconStr} alt={device.name} style={iconStyle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.deviceIcon} style={iconStyle}>
|
||||
{iconStr}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceCard({
|
||||
device,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
device: DeviceConfig;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.deviceCard} ${active ? styles.deviceCardActive : ""}`}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") onClick();
|
||||
}}
|
||||
>
|
||||
<DeviceIcon device={device} />
|
||||
<div className={styles.deviceName}>{device.name}</div>
|
||||
<div className={styles.deviceDesc}>{device.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceSelector({ selectedId, onSelect }: Props) {
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<h4>Device Type</h4>
|
||||
<div className={styles.deviceGrid}>
|
||||
{devices.map((d) => (
|
||||
<DeviceCard
|
||||
key={d.id}
|
||||
device={d}
|
||||
active={selectedId === d.id}
|
||||
onClick={() => onSelect(d.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import CodeBlock from "@theme/CodeBlock";
|
||||
import Admonition from "@theme/Admonition";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
yaml: string;
|
||||
configPath: string;
|
||||
mediaPath: string;
|
||||
hasAnyHardware: boolean;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export default function GeneratedOutput({
|
||||
yaml,
|
||||
configPath,
|
||||
mediaPath,
|
||||
hasAnyHardware,
|
||||
deviceId,
|
||||
}: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(yaml).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, [yaml]);
|
||||
|
||||
return (
|
||||
<div className={styles.resultSection}>
|
||||
<div className={styles.resultHeader}>
|
||||
<h4>Generated Configuration</h4>
|
||||
<button className="button button--primary button--sm" onClick={handleCopy}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!configPath && (
|
||||
<Admonition type="tip">
|
||||
<p>You haven't specified a config file directory. You may want to modify the default path.</p>
|
||||
</Admonition>
|
||||
)}
|
||||
{!mediaPath && (
|
||||
<Admonition type="tip">
|
||||
<p>You haven't specified a recording storage directory. You may want to modify the default path.</p>
|
||||
</Admonition>
|
||||
)}
|
||||
{deviceId === "stable" && !hasAnyHardware && (
|
||||
<Admonition type="warning">
|
||||
<p>You haven't selected any hardware acceleration. Please check if you have supported hardware available.</p>
|
||||
</Admonition>
|
||||
)}
|
||||
|
||||
<CodeBlock language="yaml" title="docker-compose.yml">
|
||||
{yaml}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from "react";
|
||||
import { hardwareOptions } from "../config";
|
||||
import type { HardwareOption } from "../config";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
deviceId: string;
|
||||
hardwareEnabled: Record<string, boolean>;
|
||||
onToggle: (hwId: string) => void;
|
||||
isDisabled: (hwId: string) => boolean;
|
||||
}
|
||||
|
||||
function renderDescription(text: string): React.ReactNode {
|
||||
const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
|
||||
return parts.map((part, i) => {
|
||||
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
if (match) {
|
||||
return <a key={i} href={match[2]}>{match[1]}</a>;
|
||||
}
|
||||
return <React.Fragment key={i}>{part}</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
function HardwareCheckbox({
|
||||
hw, disabled, checked, onToggle,
|
||||
}: {
|
||||
hw: HardwareOption; disabled: boolean; checked: boolean; onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.hardwareItem}>
|
||||
<label className={`${styles.checkboxLabel} ${disabled ? styles.checkboxDisabled : ""}`}>
|
||||
<input type="checkbox" checked={checked} onChange={onToggle} disabled={disabled} />
|
||||
<span>{hw.label}</span>
|
||||
</label>
|
||||
{checked && hw.description && (
|
||||
<div className={styles.hardwareDescription}>{renderDescription(hw.description)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HardwareOptions({ deviceId, hardwareEnabled, onToggle, isDisabled }: Props) {
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<h4>Generic Hardware Devices</h4>
|
||||
{deviceId !== "stable" && (
|
||||
<p className={styles.helpText}>
|
||||
Some options have been auto-configured based on your device type.
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.checkboxGrid}>
|
||||
{hardwareOptions.map((hw) => {
|
||||
const disabled = isDisabled(hw.id);
|
||||
const checked = disabled ? false : !!hardwareEnabled[hw.id];
|
||||
return (
|
||||
<HardwareCheckbox key={hw.id} hw={hw} disabled={disabled} checked={checked} onToggle={() => onToggle(hw.id)} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from "react";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
gpuCount: string;
|
||||
gpuDeviceId: string;
|
||||
gpuDeviceIdError: boolean;
|
||||
onGpuCountChange: (value: string) => void;
|
||||
onGpuDeviceIdChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function NvidiaGpuConfig({
|
||||
gpuCount,
|
||||
gpuDeviceId,
|
||||
gpuDeviceIdError,
|
||||
onGpuCountChange,
|
||||
onGpuDeviceIdChange,
|
||||
}: Props) {
|
||||
const showDeviceId = gpuCount !== "";
|
||||
|
||||
return (
|
||||
<div className={styles.nvidiaConfig}>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-gpu-count" className={styles.label}>
|
||||
GPU count:
|
||||
</label>
|
||||
<input
|
||||
id="dcg-gpu-count"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className={styles.input}
|
||||
value={gpuCount}
|
||||
placeholder="all"
|
||||
onChange={(e) => onGpuCountChange(e.target.value.replace(/\D/g, ""))}
|
||||
/>
|
||||
</div>
|
||||
{showDeviceId && (
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-gpu-device-id" className={styles.label}>
|
||||
GPU device IDs (required, comma-separated):
|
||||
</label>
|
||||
<input
|
||||
id="dcg-gpu-device-id"
|
||||
type="text"
|
||||
className={`${styles.input} ${gpuDeviceIdError ? styles.inputError : ""}`}
|
||||
value={gpuDeviceId}
|
||||
placeholder="0"
|
||||
onChange={(e) => onGpuDeviceIdChange(e.target.value)}
|
||||
/>
|
||||
{gpuDeviceIdError ? (
|
||||
<p className={styles.helpText}>
|
||||
⚠️ GPU device IDs are required when GPU count is a number
|
||||
</p>
|
||||
) : (
|
||||
<p className={styles.helpText}>
|
||||
Single GPU: 0 | Multiple GPUs: 0,1,2
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import React, { useMemo } from "react";
|
||||
import CodeInline from "@theme/CodeInline";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
const AUTO_TIMEZONE_VALUE = "__auto__";
|
||||
|
||||
function getTimezoneList(): string[] {
|
||||
if (typeof Intl !== "undefined") {
|
||||
const intl = Intl as typeof Intl & {
|
||||
supportedValuesOf?: (key: string) => string[];
|
||||
};
|
||||
const supported = intl.supportedValuesOf?.("timeZone");
|
||||
if (supported && supported.length > 0) {
|
||||
return [...supported].sort();
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return fallback ? [fallback] : ["UTC"];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rtspPassword: string;
|
||||
timezone: string;
|
||||
shmSize: string;
|
||||
shmSizeError: boolean;
|
||||
onRtspPasswordChange: (value: string) => void;
|
||||
onTimezoneChange: (value: string) => void;
|
||||
onShmSizeChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function OtherOptions({
|
||||
rtspPassword,
|
||||
timezone,
|
||||
shmSize,
|
||||
shmSizeError,
|
||||
onRtspPasswordChange,
|
||||
onTimezoneChange,
|
||||
onShmSizeChange,
|
||||
}: Props) {
|
||||
const timezones = useMemo(() => getTimezoneList(), []);
|
||||
const systemTimezone =
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
|
||||
const selectedValue = timezone || AUTO_TIMEZONE_VALUE;
|
||||
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<h4>Other Options</h4>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-timezone" className={styles.label}>
|
||||
Timezone:
|
||||
</label>
|
||||
<select
|
||||
id="dcg-timezone"
|
||||
className={`${styles.input} ${styles.select}`}
|
||||
value={selectedValue}
|
||||
onChange={(e) =>
|
||||
onTimezoneChange(
|
||||
e.target.value === AUTO_TIMEZONE_VALUE ? "" : e.target.value
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value={AUTO_TIMEZONE_VALUE}>
|
||||
Use browser timezone ({systemTimezone})
|
||||
</option>
|
||||
{timezones.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-shm-size" className={styles.label}>
|
||||
Shared memory (SHM):
|
||||
</label>
|
||||
<input
|
||||
id="dcg-shm-size"
|
||||
type="text"
|
||||
className={`${styles.input} ${shmSizeError ? styles.inputError : ""}`}
|
||||
value={shmSize}
|
||||
placeholder="512mb"
|
||||
onChange={(e) => onShmSizeChange(e.target.value)}
|
||||
/>
|
||||
{shmSizeError ? (
|
||||
<p className={styles.helpText}>
|
||||
⚠️ Invalid format. Use a number followed by a unit (e.g. 512mb, 1gb)
|
||||
</p>
|
||||
) : (
|
||||
<p className={styles.helpText}>
|
||||
See{" "}
|
||||
<a href="/frigate/installation#calculating-required-shm-size">
|
||||
calculating required SHM size
|
||||
</a>{" "}
|
||||
for the correct value.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-rtsp-password" className={styles.label}>
|
||||
RTSP password:
|
||||
</label>
|
||||
<input
|
||||
id="dcg-rtsp-password"
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={rtspPassword}
|
||||
placeholder="password"
|
||||
onChange={(e) => onRtspPasswordChange(e.target.value)}
|
||||
/>
|
||||
<p className={styles.helpText}>
|
||||
Optional. You can specify{" "}
|
||||
<CodeInline>{"{FRIGATE_RTSP_PASSWORD}"}</CodeInline>{" "}
|
||||
in the config file to reference camera stream passwords. This is NOT
|
||||
the Frigate login password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from "react";
|
||||
import Admonition from "@theme/Admonition";
|
||||
import { ports } from "../config";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
portEnabled: Record<string, boolean>;
|
||||
onTogglePort: (portId: string) => void;
|
||||
}
|
||||
|
||||
function PortItem({
|
||||
port,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
port: typeof ports[number];
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const showWarning = port.warningContent && (
|
||||
port.warningWhen === "checked" ? enabled :
|
||||
port.warningWhen === "unchecked" ? !enabled : enabled
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.hardwareItem}>
|
||||
<label className={`${styles.checkboxLabel} ${port.locked ? styles.checkboxDisabled : ""}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={onToggle}
|
||||
disabled={port.locked}
|
||||
/>
|
||||
<span>
|
||||
{port.locked && "🔒 "}
|
||||
Port {port.host}
|
||||
{port.protocol !== "tcp" && `/${port.protocol}`}
|
||||
</span>
|
||||
</label>
|
||||
{port.description && (
|
||||
<div className={styles.hardwareDescription}>{port.description}</div>
|
||||
)}
|
||||
{showWarning && (
|
||||
<Admonition type={port.warningType || "warning"}>
|
||||
{port.warningContent}
|
||||
</Admonition>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PortConfigSection({
|
||||
portEnabled,
|
||||
onTogglePort,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<h4>Port Configuration</h4>
|
||||
<div className={styles.checkboxGrid}>
|
||||
{ports.map((port) => (
|
||||
<PortItem
|
||||
key={port.id}
|
||||
port={port}
|
||||
enabled={!!portEnabled[port.id]}
|
||||
onToggle={() => onTogglePort(port.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import React from "react";
|
||||
import styles from "../styles.module.css";
|
||||
|
||||
interface Props {
|
||||
configPath: string;
|
||||
mediaPath: string;
|
||||
configPathError: boolean;
|
||||
mediaPathError: boolean;
|
||||
onConfigPathChange: (value: string) => void;
|
||||
onMediaPathChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function StoragePaths({
|
||||
configPath,
|
||||
mediaPath,
|
||||
configPathError,
|
||||
mediaPathError,
|
||||
onConfigPathChange,
|
||||
onMediaPathChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={styles.formSection}>
|
||||
<h4>Storage Paths</h4>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-config-path" className={styles.label}>
|
||||
Config / DB / model cache directory (on your host):
|
||||
</label>
|
||||
<input
|
||||
id="dcg-config-path"
|
||||
type="text"
|
||||
className={`${styles.input} ${configPathError ? styles.inputError : ""}`}
|
||||
value={configPath}
|
||||
placeholder="/path/to/your/config"
|
||||
onChange={(e) => onConfigPathChange(e.target.value)}
|
||||
/>
|
||||
{configPathError && (
|
||||
<p className={styles.helpText}>
|
||||
⚠️ Path contains invalid characters. Only letters, numbers,
|
||||
underscores, hyphens, slashes, and dots are allowed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="dcg-media-path" className={styles.label}>
|
||||
Recording storage directory (on your host):
|
||||
</label>
|
||||
<input
|
||||
id="dcg-media-path"
|
||||
type="text"
|
||||
className={`${styles.input} ${mediaPathError ? styles.inputError : ""}`}
|
||||
value={mediaPath}
|
||||
placeholder="/path/to/your/storage"
|
||||
onChange={(e) => onMediaPathChange(e.target.value)}
|
||||
/>
|
||||
{mediaPathError && (
|
||||
<p className={styles.helpText}>
|
||||
⚠️ Path contains invalid characters. Only letters, numbers,
|
||||
underscores, hyphens, slashes, and dots are allowed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,12 +0,0 @@
|
||||
export { devices, deviceMap } from "./devices";
|
||||
export { hardwareOptions, hardwareMap } from "./hardware";
|
||||
export { ports, portMap } from "./ports";
|
||||
|
||||
export type {
|
||||
DeviceConfig,
|
||||
DeviceMapping,
|
||||
VolumeMapping,
|
||||
HardwareOption,
|
||||
PortConfig,
|
||||
NvidiaDeployConfig,
|
||||
} from "./types";
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Type definitions for the Docker Compose Generator configuration.
|
||||
* All device, hardware, and port options are declaratively defined
|
||||
* so that adding a new device only requires editing config files.
|
||||
*/
|
||||
|
||||
/** A single device mapping entry (e.g. /dev/dri:/dev/dri) */
|
||||
export interface DeviceMapping {
|
||||
/** Host device path */
|
||||
host: string;
|
||||
/** Container device path (defaults to host if omitted) */
|
||||
container?: string;
|
||||
/** Inline comment for this device line */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/** A single volume mapping entry */
|
||||
export interface VolumeMapping {
|
||||
/** Host path */
|
||||
host: string;
|
||||
/** Container path */
|
||||
container: string;
|
||||
/** Whether the mount is read-only */
|
||||
readOnly?: boolean;
|
||||
/** Inline comment */
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
/** NVIDIA deploy configuration for docker-compose */
|
||||
export interface NvidiaDeployConfig {
|
||||
/** "all" or a specific number */
|
||||
count: string;
|
||||
/** Specific GPU device IDs (when count is a number) */
|
||||
deviceIds?: string[];
|
||||
}
|
||||
|
||||
/** Full device type definition */
|
||||
export interface DeviceConfig {
|
||||
/** Unique identifier, e.g. "intel" */
|
||||
id: string;
|
||||
/** Display name, e.g. "Intel GPU" */
|
||||
name: string;
|
||||
/** Short description */
|
||||
description: string;
|
||||
/**
|
||||
* Icon for the device card. Supports:
|
||||
* - Emoji string (e.g. "🖥️")
|
||||
* - Image URL or static path (e.g. "/img/intel.svg", "https://example.com/icon.png")
|
||||
* - Inline SVG markup (e.g. "<svg>...</svg>")
|
||||
*/
|
||||
icon: string;
|
||||
/**
|
||||
* Additional CSS properties applied to the icon element.
|
||||
* - For image-type icons: if any `background-*` property (e.g. `background-size`,
|
||||
* `background-position`) is present, the image is rendered as a CSS `background-image`
|
||||
* on the container div, enabling full background positioning control.
|
||||
* Otherwise the image is rendered as an `<img>` tag and styles apply to it.
|
||||
* - For emoji/SVG icons: styles apply to the container div.
|
||||
*/
|
||||
iconStyle?: Record<string, string>;
|
||||
/**
|
||||
* Additional CSS properties applied directly to the inner `<svg>` element
|
||||
* when the icon is an inline SVG. Use this to override the default
|
||||
* `width: 100%; height: 100%` or set `fill`, `transform`, etc.
|
||||
* Ignored for emoji and image-type icons.
|
||||
*/
|
||||
svgStyle?: Record<string, string>;
|
||||
/**
|
||||
* Icon for dark mode. Same format as `icon`. When provided, this icon
|
||||
* replaces `icon` when the user is in dark mode.
|
||||
*/
|
||||
iconDark?: string;
|
||||
/** Additional CSS properties for the dark mode icon container */
|
||||
iconDarkStyle?: Record<string, string>;
|
||||
/**
|
||||
* SVG-specific styles for dark mode. Same as `svgStyle` but applied
|
||||
* when dark mode is active. Merged over `svgStyle` in dark mode.
|
||||
*/
|
||||
svgDarkStyle?: Record<string, string>;
|
||||
/** Docker image tag, e.g. "stable" */
|
||||
imageTag: string;
|
||||
/**
|
||||
* Image tag suffix appended to the base tag.
|
||||
* e.g. "-standard-arm64" produces "stable-standard-arm64"
|
||||
*/
|
||||
imageTagSuffix?: string;
|
||||
/** Hardware option IDs to auto-enable when this device is selected */
|
||||
autoHardware: string[];
|
||||
/** Help text shown as an admonition when this device is selected */
|
||||
helpText?: string;
|
||||
/** Admonition type for help text */
|
||||
helpType?: "info" | "warning" | "danger";
|
||||
/** Device mappings always added for this device type */
|
||||
devices?: DeviceMapping[];
|
||||
/** Volume mappings always added for this device type */
|
||||
volumes?: VolumeMapping[];
|
||||
/** Extra environment variables for this device type */
|
||||
env?: Record<string, string>;
|
||||
/** NVIDIA deploy config (only for tensorrt) */
|
||||
nvidiaDeploy?: NvidiaDeployConfig;
|
||||
/** Runtime setting, e.g. "nvidia" for Jetson */
|
||||
runtime?: string;
|
||||
/** Extra hosts entries, e.g. "host.docker.internal:host-gateway" */
|
||||
extraHosts?: string[];
|
||||
/** Security options, e.g. ["apparmor=unconfined"] */
|
||||
securityOpt?: string[];
|
||||
/** Whether this device type needs the NVIDIA GPU config UI */
|
||||
needsNvidiaConfig?: boolean;
|
||||
}
|
||||
|
||||
/** Generic hardware acceleration option definition */
|
||||
export interface HardwareOption {
|
||||
/** Unique identifier, e.g. "usbCoral" */
|
||||
id: string;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/**
|
||||
* Description shown below the checkbox when this option is enabled.
|
||||
* Supports markdown link syntax: [text](url)
|
||||
*/
|
||||
description?: string;
|
||||
/** Device IDs that disable this option */
|
||||
disabledWhen?: string[];
|
||||
/** Device mappings added when this option is enabled */
|
||||
devices?: DeviceMapping[];
|
||||
/** Volume mappings added when this option is enabled */
|
||||
volumes?: VolumeMapping[];
|
||||
/** Extra environment variables */
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Port definition */
|
||||
export interface PortConfig {
|
||||
/** Unique identifier (also the default host port as string) */
|
||||
id: string;
|
||||
/** Host port number */
|
||||
host: number;
|
||||
/** Container port number */
|
||||
container: number;
|
||||
/** Protocol */
|
||||
protocol?: "tcp" | "udp";
|
||||
/** Description of the port's purpose */
|
||||
description: string;
|
||||
/** Whether enabled by default */
|
||||
defaultEnabled: boolean;
|
||||
/** Whether this port is locked (always enabled, cannot be toggled off) */
|
||||
locked?: boolean;
|
||||
/** Admonition type for the warning */
|
||||
warningType?: "warning" | "danger";
|
||||
/** Warning content (markdown) */
|
||||
warningContent?: string;
|
||||
/** When to show the warning: when the port is checked or unchecked */
|
||||
warningWhen?: "checked" | "unchecked";
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import type {
|
||||
DeviceConfig,
|
||||
DeviceMapping,
|
||||
VolumeMapping,
|
||||
} from "../config/types";
|
||||
import { hardwareMap } from "../config";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GeneratorInput {
|
||||
device: DeviceConfig;
|
||||
selectedHardware: string[];
|
||||
enabledPorts: string[];
|
||||
configPath: string;
|
||||
mediaPath: string;
|
||||
rtspPassword?: string;
|
||||
timezone: string;
|
||||
shmSize: string;
|
||||
nvidiaGpuCount?: string;
|
||||
nvidiaGpuDeviceId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deviceLine(dm: DeviceMapping): string {
|
||||
const host = dm.host;
|
||||
const container = dm.container ?? dm.host;
|
||||
const mapping = host === container ? host : `${host}:${container}`;
|
||||
const comment = dm.comment ? ` # ${dm.comment}` : "";
|
||||
return ` - ${mapping}${comment}`;
|
||||
}
|
||||
|
||||
function volumeLine(vm: VolumeMapping): string {
|
||||
const ro = vm.readOnly ? ":ro" : "";
|
||||
const comment = vm.comment ? ` # ${vm.comment}` : "";
|
||||
return ` - ${vm.host}:${vm.container}${ro}${comment}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML builder — each section returns an array of lines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildImage(device: DeviceConfig): string[] {
|
||||
const tag = device.imageTagSuffix
|
||||
? `${device.imageTag}${device.imageTagSuffix}`
|
||||
: device.imageTag;
|
||||
return [` image: ghcr.io/blakeblackshear/frigate:${tag}`];
|
||||
}
|
||||
|
||||
function buildDevices(
|
||||
device: DeviceConfig,
|
||||
hwDevices: DeviceMapping[]
|
||||
): string[] {
|
||||
const all: DeviceMapping[] = [
|
||||
...(device.devices ?? []),
|
||||
...hwDevices,
|
||||
];
|
||||
if (all.length === 0) return [];
|
||||
return [
|
||||
" devices:",
|
||||
...all.map(deviceLine),
|
||||
];
|
||||
}
|
||||
|
||||
function buildVolumes(
|
||||
device: DeviceConfig,
|
||||
hwVolumes: VolumeMapping[],
|
||||
configPath: string,
|
||||
mediaPath: string
|
||||
): string[] {
|
||||
const all: VolumeMapping[] = [
|
||||
...(device.volumes ?? []),
|
||||
...hwVolumes,
|
||||
];
|
||||
return [
|
||||
" volumes:",
|
||||
" - /etc/localtime:/etc/localtime:ro # Sync host time",
|
||||
` - ${configPath}:/config # Config file directory`,
|
||||
` - ${mediaPath}:/media/frigate # Recording storage directory`,
|
||||
" - type: tmpfs # 1GB in-memory filesystem for recording segment storage",
|
||||
" target: /tmp/cache",
|
||||
" tmpfs:",
|
||||
" size: 1000000000",
|
||||
...all.map(volumeLine),
|
||||
];
|
||||
}
|
||||
|
||||
function buildPorts(enabledPorts: string[]): string[] {
|
||||
return [
|
||||
" ports:",
|
||||
...enabledPorts,
|
||||
];
|
||||
}
|
||||
|
||||
function buildEnvironment(
|
||||
device: DeviceConfig,
|
||||
hwEnv: Record<string, string>,
|
||||
rtspPassword: string | undefined,
|
||||
timezone: string
|
||||
): string[] {
|
||||
const allEnv: Record<string, string> = {
|
||||
...hwEnv,
|
||||
...(device.env ?? {}),
|
||||
};
|
||||
|
||||
const lines: string[] = [" environment:"];
|
||||
|
||||
if (rtspPassword) {
|
||||
lines.push(
|
||||
` FRIGATE_RTSP_PASSWORD: "${rtspPassword}" # RTSP password — change to your own`
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(` TZ: "${timezone}" # Timezone`);
|
||||
|
||||
for (const [key, value] of Object.entries(allEnv)) {
|
||||
lines.push(` ${key}: "${value}"`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildDeploy(device: DeviceConfig, input: GeneratorInput): string[] {
|
||||
if (device.id === "stable-tensorrt") {
|
||||
const count = input.nvidiaGpuCount || "all";
|
||||
const isAll = count === "all";
|
||||
const deviceId = input.nvidiaGpuDeviceId?.trim();
|
||||
|
||||
if (isAll) {
|
||||
return [
|
||||
" deploy:",
|
||||
" resources:",
|
||||
" reservations:",
|
||||
" devices:",
|
||||
" - driver: nvidia",
|
||||
" count: all # Use all GPUs",
|
||||
" capabilities: [gpu]",
|
||||
];
|
||||
}
|
||||
|
||||
if (deviceId) {
|
||||
const ids = deviceId
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => `'${s}'`)
|
||||
.join(", ");
|
||||
return [
|
||||
" deploy:",
|
||||
" resources:",
|
||||
" reservations:",
|
||||
" devices:",
|
||||
" - driver: nvidia",
|
||||
` device_ids: [${ids}] # GPU device IDs`,
|
||||
` count: ${count} # GPU count`,
|
||||
" capabilities: [gpu]",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
" deploy:",
|
||||
" resources:",
|
||||
" reservations:",
|
||||
" devices:",
|
||||
" - driver: nvidia",
|
||||
` count: ${count} # GPU count`,
|
||||
" capabilities: [gpu]",
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildRuntime(device: DeviceConfig): string[] {
|
||||
if (device.runtime) {
|
||||
return [` runtime: ${device.runtime}`];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildExtraHosts(device: DeviceConfig): string[] {
|
||||
if (!device.extraHosts?.length) return [];
|
||||
return [
|
||||
" extra_hosts:",
|
||||
...device.extraHosts.map(
|
||||
(h, i) =>
|
||||
` - "${h}"${i === 0 ? " # Required to talk to the NPU detector" : ""}`
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function buildSecurityOpt(device: DeviceConfig): string[] {
|
||||
if (!device.securityOpt?.length) return [];
|
||||
return [
|
||||
" security_opt:",
|
||||
...device.securityOpt.map((s) => ` - ${s}`),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a docker-compose YAML string from the given input.
|
||||
* The output is pure YAML with inline comments (no Shiki annotations).
|
||||
*/
|
||||
export function generateDockerCompose(input: GeneratorInput): string {
|
||||
const { device } = input;
|
||||
|
||||
// Collect hardware-level devices, volumes, and env
|
||||
const hwDevices: DeviceMapping[] = [];
|
||||
const hwVolumes: VolumeMapping[] = [];
|
||||
const hwEnv: Record<string, string> = {};
|
||||
|
||||
for (const hwId of input.selectedHardware) {
|
||||
const hw = hardwareMap.get(hwId);
|
||||
if (!hw) continue;
|
||||
// Skip GPU device mapping for tensorrt images (it uses deploy instead)
|
||||
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
|
||||
hwDevices.push(...(hw.devices ?? []));
|
||||
hwVolumes.push(...(hw.volumes ?? []));
|
||||
Object.assign(hwEnv, hw.env ?? {});
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
"services:",
|
||||
" frigate:",
|
||||
" container_name: frigate",
|
||||
" privileged: true # This may not be necessary for all setups",
|
||||
" restart: unless-stopped",
|
||||
" stop_grace_period: 30s # Allow enough time to shut down the various services",
|
||||
...buildImage(device),
|
||||
` shm_size: "${input.shmSize || "512mb"}" # Update for your cameras based on SHM calculation`,
|
||||
...buildRuntime(device),
|
||||
...buildDeploy(device, input),
|
||||
...buildExtraHosts(device),
|
||||
...buildSecurityOpt(device),
|
||||
...buildDevices(device, hwDevices),
|
||||
...buildVolumes(device, hwVolumes, input.configPath, input.mediaPath),
|
||||
...buildPorts(input.enabledPorts),
|
||||
...buildEnvironment(device, hwEnv, input.rtspPassword, input.timezone),
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { deviceMap, hardwareMap, portMap } from "../config";
|
||||
import { generateDockerCompose } from "../generator";
|
||||
import type { GeneratorInput } from "../generator";
|
||||
|
||||
/**
|
||||
* Main hook that holds all form state and generates the Docker Compose output.
|
||||
* Configuration is loaded synchronously from build-time generated .ts files.
|
||||
*/
|
||||
export function useConfigGenerator() {
|
||||
const [deviceId, setDeviceId] = useState("stable");
|
||||
|
||||
const [hardwareEnabled, setHardwareEnabled] = useState<Record<string, boolean>>(() => {
|
||||
const defaultDevice = deviceMap.get("stable");
|
||||
const initial: Record<string, boolean> = {};
|
||||
if (defaultDevice) {
|
||||
for (const hwId of defaultDevice.autoHardware) {
|
||||
initial[hwId] = true;
|
||||
}
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [portEnabled, setPortEnabled] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
for (const p of portMap.values()) {
|
||||
initial[p.id] = p.defaultEnabled;
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const [nvidiaGpuCount, setNvidiaGpuCount] = useState("");
|
||||
const [nvidiaGpuDeviceId, setNvidiaGpuDeviceId] = useState("");
|
||||
const [configPath, setConfigPath] = useState("");
|
||||
const [mediaPath, setMediaPath] = useState("");
|
||||
const [rtspPassword, setRtspPassword] = useState("");
|
||||
const [timezone, setTimezone] = useState("");
|
||||
const [shmSize, setShmSize] = useState("512mb");
|
||||
const [shmSizeError, setShmSizeError] = useState(false);
|
||||
const [gpuDeviceIdError, setGpuDeviceIdError] = useState(false);
|
||||
const [configPathError, setConfigPathError] = useState(false);
|
||||
const [mediaPathError, setMediaPathError] = useState(false);
|
||||
|
||||
const device = useMemo(() => deviceMap.get(deviceId)!, [deviceId]);
|
||||
|
||||
const selectDevice = useCallback((id: string) => {
|
||||
const newDevice = deviceMap.get(id);
|
||||
if (!newDevice) return;
|
||||
setDeviceId(id);
|
||||
setHardwareEnabled(() => {
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const hwId of newDevice.autoHardware) {
|
||||
next[hwId] = true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setNvidiaGpuCount("");
|
||||
setNvidiaGpuDeviceId("");
|
||||
setGpuDeviceIdError(false);
|
||||
}, []);
|
||||
|
||||
const toggleHardware = useCallback((hwId: string) => {
|
||||
setHardwareEnabled((prev) => ({ ...prev, [hwId]: !prev[hwId] }));
|
||||
}, []);
|
||||
|
||||
const togglePort = useCallback((portId: string) => {
|
||||
const port = portMap.get(portId);
|
||||
if (port?.locked) return;
|
||||
setPortEnabled((prev) => ({ ...prev, [portId]: !prev[portId] }));
|
||||
}, []);
|
||||
|
||||
const isHardwareDisabled = useCallback(
|
||||
(hwId: string): boolean => {
|
||||
const hw = hardwareMap.get(hwId);
|
||||
if (!hw) return false;
|
||||
return hw.disabledWhen?.includes(deviceId) ?? false;
|
||||
},
|
||||
[deviceId]
|
||||
);
|
||||
|
||||
const validateShmSize = useCallback((value: string): boolean => {
|
||||
if (!value) return true;
|
||||
return /^\d+(\.\d+)?[bkmgBKMG]{1,2}$/.test(value);
|
||||
}, []);
|
||||
|
||||
const validatePath = useCallback((value: string): boolean => {
|
||||
if (!value) return true;
|
||||
return /^[a-zA-Z0-9_\-/./]+$/.test(value);
|
||||
}, []);
|
||||
|
||||
const handleShmSizeChange = useCallback(
|
||||
(value: string) => {
|
||||
const filtered = value.replace(/[^0-9.bkmgBKMG]/g, "");
|
||||
const valid = validateShmSize(filtered);
|
||||
setShmSize(filtered);
|
||||
setShmSizeError(!valid && filtered !== "");
|
||||
},
|
||||
[validateShmSize]
|
||||
);
|
||||
|
||||
const handleConfigPathChange = useCallback(
|
||||
(value: string) => {
|
||||
const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, "");
|
||||
const valid = validatePath(filtered);
|
||||
setConfigPath(filtered);
|
||||
setConfigPathError(!valid && filtered !== "");
|
||||
},
|
||||
[validatePath]
|
||||
);
|
||||
|
||||
const handleMediaPathChange = useCallback(
|
||||
(value: string) => {
|
||||
const filtered = value.replace(/[^a-zA-Z0-9_\-/./]/g, "");
|
||||
const valid = validatePath(filtered);
|
||||
setMediaPath(filtered);
|
||||
setMediaPathError(!valid && filtered !== "");
|
||||
},
|
||||
[validatePath]
|
||||
);
|
||||
|
||||
const handleNvidiaGpuCountChange = useCallback((value: string) => {
|
||||
// Only allow digits
|
||||
setNvidiaGpuCount(value);
|
||||
if (value === "") {
|
||||
setNvidiaGpuDeviceId("");
|
||||
setGpuDeviceIdError(false);
|
||||
} else {
|
||||
setGpuDeviceIdError(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNvidiaGpuDeviceIdChange = useCallback((value: string) => {
|
||||
setNvidiaGpuDeviceId(value.trim());
|
||||
setGpuDeviceIdError(false);
|
||||
}, []);
|
||||
|
||||
const enabledPortLines = useMemo(() => {
|
||||
const lines: string[] = [];
|
||||
for (const [id, enabled] of Object.entries(portEnabled)) {
|
||||
if (!enabled) continue;
|
||||
const p = portMap.get(id);
|
||||
if (!p) continue;
|
||||
const proto = p.protocol && p.protocol !== "tcp" ? `/${p.protocol}` : "";
|
||||
const comment = p.description ? ` # ${p.description}` : "";
|
||||
lines.push(` - "${p.host}:${p.container}${proto}"${comment}`);
|
||||
}
|
||||
return lines;
|
||||
}, [portEnabled]);
|
||||
|
||||
const selectedHardwareIds = useMemo(() => {
|
||||
return Object.entries(hardwareEnabled)
|
||||
.filter(([id, enabled]) => {
|
||||
if (!enabled) return false;
|
||||
const hw = hardwareMap.get(id);
|
||||
if (!hw) return false;
|
||||
if (hw.disabledWhen?.includes(deviceId)) return false;
|
||||
return true;
|
||||
})
|
||||
.map(([id]) => id);
|
||||
}, [hardwareEnabled, deviceId]);
|
||||
|
||||
const generatedYaml = useMemo(() => {
|
||||
const input: GeneratorInput = {
|
||||
device,
|
||||
selectedHardware: selectedHardwareIds,
|
||||
enabledPorts: enabledPortLines,
|
||||
configPath: configPath || "/path/to/your/config",
|
||||
mediaPath: mediaPath || "/path/to/your/storage",
|
||||
rtspPassword,
|
||||
timezone: timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC",
|
||||
shmSize: shmSize || "512mb",
|
||||
nvidiaGpuCount,
|
||||
nvidiaGpuDeviceId,
|
||||
};
|
||||
return generateDockerCompose(input);
|
||||
}, [
|
||||
device, selectedHardwareIds, enabledPortLines,
|
||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||
]);
|
||||
|
||||
const hasAnyHardware = selectedHardwareIds.length > 0 || !!device?.devices?.length;
|
||||
|
||||
return {
|
||||
deviceId, device, hardwareEnabled, portEnabled,
|
||||
nvidiaGpuCount, nvidiaGpuDeviceId,
|
||||
configPath, mediaPath, rtspPassword, timezone, shmSize,
|
||||
shmSizeError, gpuDeviceIdError, configPathError, mediaPathError,
|
||||
hasAnyHardware, generatedYaml,
|
||||
selectDevice, toggleHardware, togglePort,
|
||||
handleShmSizeChange, handleConfigPathChange, handleMediaPathChange,
|
||||
handleNvidiaGpuCountChange, handleNvidiaGpuDeviceIdChange,
|
||||
setRtspPassword, setTimezone, isHardwareDisabled,
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./DockerComposeGenerator";
|
||||
@@ -1,381 +0,0 @@
|
||||
/* ===================================================================
|
||||
Docker Compose Generator — styles
|
||||
Uses Docusaurus / Infima CSS variables for theme compatibility.
|
||||
=================================================================== */
|
||||
|
||||
.generator {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--ifm-background-surface-color);
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: var(--ifm-global-shadow-lw);
|
||||
}
|
||||
|
||||
[data-theme="light"] .card {
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
/* --- Form sections --- */
|
||||
|
||||
.formSection {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-400);
|
||||
}
|
||||
|
||||
.formSection:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.formSection h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--ifm-font-color-base);
|
||||
font-size: 1.1rem;
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
}
|
||||
|
||||
/* --- Form controls --- */
|
||||
|
||||
.formGroup {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.formGroup:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--ifm-font-color-base);
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 6px;
|
||||
background: var(--ifm-background-color);
|
||||
color: var(--ifm-font-color-base);
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
[data-theme="light"] .input {
|
||||
background: #fff;
|
||||
border: 1px solid #d0d7de;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--ifm-color-primary);
|
||||
box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .input {
|
||||
border-color: var(--ifm-color-emphasis-300);
|
||||
}
|
||||
|
||||
.inputError {
|
||||
border-color: #e74c3c;
|
||||
animation: shake 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Select dropdown --- */
|
||||
|
||||
.select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--ifm-background-color)
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E")
|
||||
no-repeat right 0.75rem center / 12px 12px;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
[data-theme="light"] .select {
|
||||
background: #fff
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23555' d='M6 8L1 3h10z'/%3E%3C/svg%3E")
|
||||
no-repeat right 0.75rem center / 12px 12px;
|
||||
}
|
||||
|
||||
.helpText {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.helpText a {
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
/* --- Device grid --- */
|
||||
|
||||
.deviceGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.deviceCard {
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
background: var(--ifm-background-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-theme="light"] .deviceCard {
|
||||
border: 2px solid #d0d7de;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.deviceCard:hover {
|
||||
border-color: var(--ifm-color-primary);
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.deviceCardActive {
|
||||
border-color: var(--ifm-color-primary);
|
||||
background: var(--ifm-color-primary-lightest);
|
||||
box-shadow: 0 0 0 1px var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .deviceCardActive {
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 12%, #fff);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .deviceCardActive {
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 25%, #1b1b1b);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .deviceCardActive .deviceName {
|
||||
color: var(--ifm-color-primary-light);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .deviceCardActive .deviceDesc {
|
||||
color: var(--ifm-color-primary-light);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.deviceIcon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.deviceIconSvg {
|
||||
margin-bottom: 0.25rem;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
/* Allow iconStyle width/height to override */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deviceIconSvg svg {
|
||||
width: var(--svg-width, 100%);
|
||||
height: var(--svg-height, 100%);
|
||||
fill: var(--svg-fill, currentColor);
|
||||
transform: var(--svg-transform, none);
|
||||
}
|
||||
|
||||
.deviceIconImage {
|
||||
margin-bottom: 0.25rem;
|
||||
height: 40px;
|
||||
width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.deviceIconImage img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.deviceName {
|
||||
font-weight: var(--ifm-font-weight-semibold);
|
||||
color: var(--ifm-font-color-base);
|
||||
margin-bottom: 0.15rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.deviceDesc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* --- Checkbox grid --- */
|
||||
|
||||
.checkboxGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.checkboxGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.hardwareItem {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hardwareDescription {
|
||||
margin: 0.15rem 0 0.4rem 1.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ifm-font-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hardwareDescription a {
|
||||
color: var(--ifm-color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.checkboxLabel:hover {
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
}
|
||||
|
||||
.checkboxLabel input[type="checkbox"] {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkboxLabel span {
|
||||
color: var(--ifm-font-color-base);
|
||||
}
|
||||
|
||||
.checkboxDisabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkboxDisabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.checkboxDisabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* --- Form grid (side-by-side) --- */
|
||||
|
||||
.formGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.formGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.formGrid .formGroup {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* --- Port section --- */
|
||||
|
||||
.portSection {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.warningBadge {
|
||||
margin-left: auto;
|
||||
color: #e67e22;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* --- NVIDIA config --- */
|
||||
|
||||
.nvidiaConfig {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--ifm-background-color);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nvidiaConfig {
|
||||
background: #f6f8fa;
|
||||
border-left: 3px solid var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
/* --- Result section --- */
|
||||
|
||||
.resultSection {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.resultHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.resultHeader h4 {
|
||||
margin: 0;
|
||||
color: var(--ifm-font-color-base);
|
||||
}
|
||||
Vendored
+2
-13
@@ -5997,10 +5997,7 @@ paths:
|
||||
tags:
|
||||
- App
|
||||
summary: Start debug replay
|
||||
description:
|
||||
Start a debug replay session from camera recordings. Returns
|
||||
immediately while clip generation runs as a background job; subscribe
|
||||
to the 'debug_replay' job_state WS topic to track progress.
|
||||
description: Start a debug replay session from camera recordings.
|
||||
operationId: start_debug_replay_debug_replay_start_post
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -6009,16 +6006,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DebugReplayStartBody"
|
||||
responses:
|
||||
"202":
|
||||
"200":
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DebugReplayStartResponse"
|
||||
"400":
|
||||
description: Invalid camera, time range, or no recordings
|
||||
"409":
|
||||
description: A replay session is already active
|
||||
"422":
|
||||
description: Validation Error
|
||||
content:
|
||||
@@ -6279,14 +6272,10 @@ components:
|
||||
replay_camera:
|
||||
type: string
|
||||
title: Replay Camera
|
||||
job_id:
|
||||
type: string
|
||||
title: Job Id
|
||||
type: object
|
||||
required:
|
||||
- success
|
||||
- replay_camera
|
||||
- job_id
|
||||
title: DebugReplayStartResponse
|
||||
description: Response for starting a debug replay session.
|
||||
DebugReplayStatusResponse:
|
||||
|
||||
+1
-44
@@ -146,13 +146,8 @@ def config(request: Request):
|
||||
for name, detector in config_obj.detectors.items()
|
||||
}
|
||||
|
||||
# remove environment_vars for non-admin users
|
||||
if request.headers.get("remote-role") != "admin":
|
||||
config.pop("environment_vars", None)
|
||||
|
||||
# remove mqtt credentials
|
||||
# remove the mqtt password
|
||||
config["mqtt"].pop("password", None)
|
||||
config["mqtt"].pop("user", None)
|
||||
|
||||
# remove the proxy secret
|
||||
config["proxy"].pop("auth_secret", None)
|
||||
@@ -499,40 +494,6 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
)
|
||||
|
||||
|
||||
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
|
||||
"""Substitute incoming `*:*` masked credentials with the in-memory ones.
|
||||
|
||||
The /config response masks ffmpeg input credentials, so the settings UI
|
||||
sends the masked path back when sibling fields (e.g. hwaccel_args) are
|
||||
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
|
||||
the real credentials. Mutates `config_data` in place.
|
||||
"""
|
||||
cameras = config_data.get("cameras")
|
||||
if not isinstance(cameras, dict):
|
||||
return
|
||||
|
||||
for camera_name, camera_data in cameras.items():
|
||||
if not isinstance(camera_data, dict):
|
||||
continue
|
||||
inputs = camera_data.get("ffmpeg", {}).get("inputs")
|
||||
if not isinstance(inputs, list):
|
||||
continue
|
||||
existing = config.cameras.get(camera_name)
|
||||
if existing is None:
|
||||
continue
|
||||
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
|
||||
for index, input_obj in enumerate(inputs):
|
||||
if not isinstance(input_obj, dict):
|
||||
continue
|
||||
path = input_obj.get("path")
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
|
||||
existing_paths
|
||||
):
|
||||
input_obj["path"] = existing_paths[index]
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
"""Apply config changes in-memory only, without writing to YAML.
|
||||
|
||||
@@ -543,7 +504,6 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
try:
|
||||
updates = {}
|
||||
if body.config_data:
|
||||
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
|
||||
@@ -650,9 +610,6 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
if query_string:
|
||||
updates = process_config_query_string(query_string)
|
||||
elif body.config_data:
|
||||
_restore_masked_camera_paths(
|
||||
body.config_data, request.app.frigate_config
|
||||
)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
|
||||
@@ -812,11 +812,6 @@ limiter = Limiter(key_func=get_remote_addr)
|
||||
)
|
||||
@limiter.limit(limit_value=rateLimiter.get_limit)
|
||||
def login(request: Request, body: AppPostLoginBody):
|
||||
if not request.app.frigate_config.auth.enabled:
|
||||
return JSONResponse(
|
||||
content={"message": "Authentication is disabled"}, status_code=404
|
||||
)
|
||||
|
||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||
|
||||
+1
-14
@@ -37,7 +37,6 @@ from frigate.api.defs.response.chat_response import (
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.api.event import events
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.ui import UnitSystemEnum
|
||||
from frigate.genai.utils import build_assistant_message_for_conversation
|
||||
from frigate.jobs.vlm_watch import (
|
||||
get_vlm_watch_job,
|
||||
@@ -1302,7 +1301,6 @@ async def chat_completion(
|
||||
|
||||
cameras_info = []
|
||||
config = request.app.frigate_config
|
||||
has_speed_zone = False
|
||||
for camera_id in allowed_cameras:
|
||||
if camera_id not in config.cameras:
|
||||
continue
|
||||
@@ -1313,10 +1311,6 @@ async def chat_completion(
|
||||
else camera_id.replace("_", " ").title()
|
||||
)
|
||||
zone_names = list(camera_config.zones.keys())
|
||||
if not has_speed_zone:
|
||||
has_speed_zone = any(
|
||||
zone.distances for zone in camera_config.zones.values()
|
||||
)
|
||||
if zone_names:
|
||||
cameras_info.append(
|
||||
f" - {friendly_name} (ID: {camera_id}, zones: {', '.join(zone_names)})"
|
||||
@@ -1332,13 +1326,6 @@ async def chat_completion(
|
||||
+ "\n\nWhen users refer to cameras by their friendly name (e.g., 'Back Deck Camera'), use the corresponding camera ID (e.g., 'back_deck_cam') in tool calls."
|
||||
)
|
||||
|
||||
speed_units_section = ""
|
||||
if has_speed_zone:
|
||||
speed_unit = (
|
||||
"mph" if config.ui.unit_system == UnitSystemEnum.imperial else "km/h"
|
||||
)
|
||||
speed_units_section = f"\n\nReport object speeds to the user in {speed_unit}."
|
||||
|
||||
system_prompt = 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.
|
||||
|
||||
Current server local date and time: {current_date_str} at {current_time_str}
|
||||
@@ -1350,7 +1337,7 @@ When users ask about "today", "yesterday", "this week", etc., use the current da
|
||||
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 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.{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.{cameras_section}"""
|
||||
|
||||
conversation.append(
|
||||
{
|
||||
|
||||
+30
-44
@@ -10,7 +10,6 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.jobs.debug_replay import start_debug_replay_job
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,17 +29,10 @@ class DebugReplayStartResponse(BaseModel):
|
||||
|
||||
success: bool
|
||||
replay_camera: str
|
||||
job_id: str
|
||||
|
||||
|
||||
class DebugReplayStatusResponse(BaseModel):
|
||||
"""Response for debug replay status.
|
||||
|
||||
Returns only session-presence fields. Startup progress and error
|
||||
details flow through the job_state WebSocket topic via the
|
||||
debug_replay job (see frigate.jobs.debug_replay); the
|
||||
Replay page subscribes there with useJobStatus("debug_replay").
|
||||
"""
|
||||
"""Response for debug replay status."""
|
||||
|
||||
active: bool
|
||||
replay_camera: str | None = None
|
||||
@@ -59,32 +51,15 @@ class DebugReplayStopResponse(BaseModel):
|
||||
@router.post(
|
||||
"/debug_replay/start",
|
||||
response_model=DebugReplayStartResponse,
|
||||
status_code=202,
|
||||
responses={
|
||||
400: {"description": "Invalid camera, time range, or no recordings"},
|
||||
409: {"description": "A replay session is already active"},
|
||||
},
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
summary="Start debug replay",
|
||||
description="Start a debug replay session from camera recordings. Returns "
|
||||
"immediately while clip generation runs as a background job; subscribe "
|
||||
"to the 'debug_replay' job_state WS topic to track progress.",
|
||||
description="Start a debug replay session from camera recordings.",
|
||||
)
|
||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
"""Start a debug replay session asynchronously."""
|
||||
"""Start a debug replay session."""
|
||||
replay_manager = request.app.replay_manager
|
||||
|
||||
try:
|
||||
job_id = await asyncio.to_thread(
|
||||
start_debug_replay_job,
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
replay_manager=replay_manager,
|
||||
)
|
||||
except RuntimeError:
|
||||
if replay_manager.active:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
@@ -92,23 +67,38 @@ async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
try:
|
||||
replay_camera = await asyncio.to_thread(
|
||||
replay_manager.start,
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
frigate_config=request.app.frigate_config,
|
||||
config_publisher=request.app.config_publisher,
|
||||
)
|
||||
except ValueError:
|
||||
logger.exception("Rejected debug replay start request")
|
||||
logger.exception("Invalid parameters for debug replay start request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid debug replay parameters",
|
||||
"message": "Invalid debug replay request parameters",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
except RuntimeError:
|
||||
logger.exception("Error while starting debug replay session")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "An internal error occurred while starting debug replay",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"replay_camera": replay_manager.replay_camera_name,
|
||||
"job_id": job_id,
|
||||
},
|
||||
status_code=202,
|
||||
return DebugReplayStartResponse(
|
||||
success=True,
|
||||
replay_camera=replay_camera,
|
||||
)
|
||||
|
||||
|
||||
@@ -128,16 +118,12 @@ def get_debug_replay_status(request: Request):
|
||||
|
||||
if replay_manager.active and replay_camera:
|
||||
frame_processor = request.app.detected_frames_processor
|
||||
frame = (
|
||||
frame_processor.get_current_frame(replay_camera)
|
||||
if frame_processor is not None
|
||||
else None
|
||||
)
|
||||
frame = frame_processor.get_current_frame(replay_camera)
|
||||
|
||||
if frame is not None:
|
||||
frame_time = frame_processor.get_current_frame_time(replay_camera)
|
||||
camera_config = request.app.frigate_config.cameras.get(replay_camera)
|
||||
retry_interval = 10.0
|
||||
retry_interval = 10
|
||||
|
||||
if camera_config is not None:
|
||||
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
||||
|
||||
@@ -174,10 +174,12 @@ async def latest_frame(
|
||||
}
|
||||
quality_params = get_image_quality_params(extension.value, params.quality)
|
||||
|
||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
||||
if camera_config is not None:
|
||||
if camera_name in request.app.frigate_config.cameras:
|
||||
frame = frame_processor.get_current_frame(camera_name, draw_options)
|
||||
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
||||
retry_interval = float(
|
||||
request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval
|
||||
or 10
|
||||
)
|
||||
|
||||
is_offline = False
|
||||
if frame is None or datetime.now().timestamp() > (
|
||||
|
||||
@@ -429,10 +429,7 @@ class WebPushClient(Communicator):
|
||||
else:
|
||||
title = base_title
|
||||
|
||||
if payload["after"]["data"]["metadata"].get("shortSummary"):
|
||||
message = payload["after"]["data"]["metadata"]["shortSummary"]
|
||||
else:
|
||||
message = f"Detected on {camera_name}"
|
||||
message = payload["after"]["data"]["metadata"]["shortSummary"]
|
||||
else:
|
||||
zone_names = payload["after"]["data"]["zones"]
|
||||
formatted_zone_names = []
|
||||
|
||||
+3
-99
@@ -17,90 +17,9 @@ from ws4py.websocket import WebSocket as WebSocket_
|
||||
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import (
|
||||
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
INSERT_MANY_RECORDINGS,
|
||||
INSERT_PREVIEW,
|
||||
NOTIFICATION_TEST,
|
||||
REQUEST_REGION_GRID,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Internal IPC topics — NEVER allowed from WebSocket, regardless of role
|
||||
_WS_BLOCKED_TOPICS = frozenset(
|
||||
{
|
||||
INSERT_MANY_RECORDINGS,
|
||||
INSERT_PREVIEW,
|
||||
REQUEST_REGION_GRID,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
||||
UPDATE_CAMERA_ACTIVITY,
|
||||
UPDATE_AUDIO_ACTIVITY,
|
||||
EXPIRE_AUDIO_ACTIVITY,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
NOTIFICATION_TEST,
|
||||
}
|
||||
)
|
||||
|
||||
# Read-only topics any authenticated user (including viewer) can send
|
||||
_WS_VIEWER_TOPICS = frozenset(
|
||||
{
|
||||
"onConnect",
|
||||
"modelState",
|
||||
"audioTranscriptionState",
|
||||
"birdseyeLayout",
|
||||
"embeddingsReindexProgress",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_ws_authorization(
|
||||
topic: str,
|
||||
role_header: str | None,
|
||||
separator: str,
|
||||
) -> bool:
|
||||
"""Check if a WebSocket message is authorized.
|
||||
|
||||
Args:
|
||||
topic: The message topic.
|
||||
role_header: The HTTP_REMOTE_ROLE header value, or None.
|
||||
separator: The role separator character from proxy config.
|
||||
|
||||
Returns:
|
||||
True if authorized, False if blocked.
|
||||
"""
|
||||
# Block IPC-only topics unconditionally
|
||||
if topic in _WS_BLOCKED_TOPICS:
|
||||
return False
|
||||
|
||||
# No role header: default to viewer (fail-closed)
|
||||
if role_header is None:
|
||||
return topic in _WS_VIEWER_TOPICS
|
||||
|
||||
# Check if any role is admin
|
||||
roles = [r.strip() for r in role_header.split(separator)]
|
||||
if "admin" in roles:
|
||||
return True
|
||||
|
||||
# Non-admin: only viewer topics allowed
|
||||
return topic in _WS_VIEWER_TOPICS
|
||||
|
||||
|
||||
class WebSocket(WebSocket_): # type: ignore[misc]
|
||||
def unhandled_error(self, error: Any) -> None:
|
||||
@@ -130,7 +49,6 @@ class WebSocketClient(Communicator):
|
||||
|
||||
class _WebSocketHandler(WebSocket):
|
||||
receiver = self._dispatcher
|
||||
role_separator = self.config.proxy.separator or ","
|
||||
|
||||
def received_message(self, message: WebSocket.received_message) -> None: # type: ignore[name-defined]
|
||||
try:
|
||||
@@ -145,25 +63,11 @@ class WebSocketClient(Communicator):
|
||||
)
|
||||
return
|
||||
|
||||
topic = json_message["topic"]
|
||||
|
||||
# Authorization check (skip when environ is None — direct internal connection)
|
||||
role_header = (
|
||||
self.environ.get("HTTP_REMOTE_ROLE") if self.environ else None
|
||||
logger.debug(
|
||||
f"Publishing mqtt message from websockets at {json_message['topic']}."
|
||||
)
|
||||
if self.environ is not None and not _check_ws_authorization(
|
||||
topic, role_header, self.role_separator
|
||||
):
|
||||
logger.warning(
|
||||
"Blocked unauthorized WebSocket message: topic=%s, role=%s",
|
||||
topic,
|
||||
role_header,
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(f"Publishing mqtt message from websockets at {topic}.")
|
||||
self.receiver(
|
||||
topic,
|
||||
json_message["topic"],
|
||||
json_message["payload"],
|
||||
)
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
# Options with global fallback
|
||||
audio: AudioConfig = Field(
|
||||
default_factory=AudioConfig,
|
||||
title="Audio detection",
|
||||
title="Audio events",
|
||||
description="Settings for audio-based event detection for this camera.",
|
||||
)
|
||||
audio_transcription: CameraAudioTranscriptionConfig = Field(
|
||||
|
||||
@@ -41,7 +41,8 @@ class GenAIConfig(FrigateBaseModel):
|
||||
title="Model",
|
||||
description="The model to use from the provider for generating descriptions or summaries.",
|
||||
)
|
||||
provider: GenAIProviderEnum = Field(
|
||||
provider: GenAIProviderEnum | None = Field(
|
||||
default=None,
|
||||
title="Provider",
|
||||
description="The GenAI provider to use (for example: ollama, gemini, openai).",
|
||||
)
|
||||
|
||||
@@ -121,10 +121,7 @@ class CameraConfigUpdateSubscriber:
|
||||
elif update_type == CameraConfigUpdateEnum.objects:
|
||||
config.objects = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.record:
|
||||
old_enabled_in_config = config.record.enabled_in_config
|
||||
config.record = updated_config
|
||||
if old_enabled_in_config != updated_config.enabled_in_config:
|
||||
config.recreate_ffmpeg_cmds()
|
||||
elif update_type == CameraConfigUpdateEnum.review:
|
||||
config.review = updated_config
|
||||
elif update_type == CameraConfigUpdateEnum.review_genai:
|
||||
|
||||
@@ -26,11 +26,6 @@ class EnrichmentsDeviceEnum(str, Enum):
|
||||
CPU = "CPU"
|
||||
|
||||
|
||||
class ModelSizeEnum(str, Enum):
|
||||
small = "small"
|
||||
large = "large"
|
||||
|
||||
|
||||
class TriggerType(str, Enum):
|
||||
THUMBNAIL = "thumbnail"
|
||||
DESCRIPTION = "description"
|
||||
@@ -58,13 +53,13 @@ class AudioTranscriptionConfig(FrigateBaseModel):
|
||||
title="Transcription language",
|
||||
description="Language code used for transcription/translation (for example 'en' for English). See https://whisper-api.com/docs/languages/ for supported language codes.",
|
||||
)
|
||||
device: EnrichmentsDeviceEnum = Field(
|
||||
device: Optional[EnrichmentsDeviceEnum] = Field(
|
||||
default=EnrichmentsDeviceEnum.CPU,
|
||||
title="Transcription device",
|
||||
description="Device key (CPU/GPU) to run the transcription model on. Only NVIDIA CUDA GPUs are currently supported for transcription.",
|
||||
)
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
title="Model size",
|
||||
description="Model size to use for offline audio event transcription.",
|
||||
)
|
||||
@@ -194,8 +189,8 @@ class SemanticSearchConfig(FrigateBaseModel):
|
||||
return v
|
||||
return v
|
||||
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
title="Model size",
|
||||
description="Select model size; 'small' runs on CPU and 'large' typically requires GPU.",
|
||||
)
|
||||
@@ -258,8 +253,8 @@ class FaceRecognitionConfig(FrigateBaseModel):
|
||||
title="Enable face recognition",
|
||||
description="Enable or disable face recognition for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
title="Model size",
|
||||
description="Model size to use for face embeddings (small/large); larger may require GPU.",
|
||||
)
|
||||
@@ -340,8 +335,8 @@ class LicensePlateRecognitionConfig(FrigateBaseModel):
|
||||
title="Enable LPR",
|
||||
description="Enable or disable license plate recognition for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
title="Model size",
|
||||
description="Model size used for text detection/recognition. Most users should use 'small'.",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -81,40 +80,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
yaml = YAML()
|
||||
|
||||
DEFAULT_DETECTORS = {
|
||||
"ov": {
|
||||
"type": "openvino",
|
||||
"device": "CPU",
|
||||
}
|
||||
}
|
||||
DEFAULT_MODEL = {
|
||||
"width": 300,
|
||||
"height": 300,
|
||||
"input_tensor": "nhwc",
|
||||
"input_pixel_format": "bgr",
|
||||
"path": "/openvino-model/ssdlite_mobilenet_v2.xml",
|
||||
"labelmap_path": "/openvino-model/coco_91cl_bkgr.txt",
|
||||
}
|
||||
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
|
||||
|
||||
|
||||
def _render_default_yaml(data: dict) -> str:
|
||||
buf = io.StringIO()
|
||||
_yaml_writer = YAML()
|
||||
_yaml_writer.indent(mapping=2, sequence=4, offset=2)
|
||||
_yaml_writer.dump(data, buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
DEFAULT_CONFIG = f"""
|
||||
mqtt:
|
||||
enabled: False
|
||||
|
||||
{_render_default_yaml({"detectors": DEFAULT_DETECTORS, "model": DEFAULT_MODEL})}
|
||||
cameras: {{}} # No cameras defined, UI wizard should be used
|
||||
version: {CURRENT_CONFIG_VERSION}
|
||||
"""
|
||||
|
||||
DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}}
|
||||
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
|
||||
|
||||
# stream info handler
|
||||
stream_info_retriever = StreamInfoRetriever()
|
||||
|
||||
@@ -477,7 +453,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
cameras: Dict[str, CameraConfig] = Field(title="Cameras", description="Cameras")
|
||||
audio: AudioConfig = Field(
|
||||
default_factory=AudioConfig,
|
||||
title="Audio detection",
|
||||
title="Audio events",
|
||||
description="Settings for audio-based event detection for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
birdseye: BirdseyeConfig = Field(
|
||||
@@ -703,9 +679,6 @@ class FrigateConfig(FrigateBaseModel):
|
||||
model_config["path"] = "/cpu_model.tflite"
|
||||
elif detector_config.type == "edgetpu":
|
||||
model_config["path"] = "/edgetpu_model.tflite"
|
||||
elif detector_config.type == "openvino":
|
||||
for default_key, default_value in DEFAULT_MODEL.items():
|
||||
model_config.setdefault(default_key, default_value)
|
||||
|
||||
model = ModelConfig.model_validate(model_config)
|
||||
model.check_and_load_plus_model(self.plus_api, detector_config.type)
|
||||
|
||||
@@ -25,8 +25,8 @@ class StatsConfig(FrigateBaseModel):
|
||||
)
|
||||
intel_gpu_device: Optional[str] = Field(
|
||||
default=None,
|
||||
title="Intel GPU device",
|
||||
description="PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present.",
|
||||
title="SR-IOV device",
|
||||
description="Device identifier used when treating Intel GPUs as SR-IOV to fix GPU stats.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1073,6 +1073,10 @@ class LicensePlateProcessingMixin:
|
||||
top_score = score
|
||||
top_box = bbox
|
||||
|
||||
if score > top_score:
|
||||
top_score = score
|
||||
top_box = bbox
|
||||
|
||||
# Return the top scoring bounding box if found
|
||||
if top_box is not None:
|
||||
# expand box by 5% to help with OCR
|
||||
@@ -1088,6 +1092,9 @@ class LicensePlateProcessingMixin:
|
||||
]
|
||||
).clip(0, [input.shape[1], input.shape[0]] * 2)
|
||||
|
||||
logger.debug(
|
||||
f"{camera}: Found license plate. Bounding box: {expanded_box.astype(int)}"
|
||||
)
|
||||
return tuple(int(x) for x in expanded_box) # type: ignore[return-value]
|
||||
else:
|
||||
return None # No detection above the threshold
|
||||
@@ -1353,8 +1360,8 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
|
||||
# check that license plate is valid
|
||||
# quadruple the value because we've doubled both dimensions of the car
|
||||
if license_plate_area < self.config.cameras[camera].lpr.min_area * 4:
|
||||
# double the value because we've doubled the size of the car
|
||||
if license_plate_area < self.config.cameras[camera].lpr.min_area * 2:
|
||||
logger.debug(f"{camera}: License plate is less than min_area")
|
||||
return
|
||||
|
||||
@@ -1458,7 +1465,6 @@ class LicensePlateProcessingMixin:
|
||||
license_plate_frame,
|
||||
)
|
||||
|
||||
logger.debug(f"{camera}: Found license plate. Bounding box: {list(plate_box)}")
|
||||
logger.debug(f"{camera}: Running plate recognition for id: {id}.")
|
||||
|
||||
# run detection, returns results sorted by confidence, best first
|
||||
|
||||
@@ -25,15 +25,15 @@ class ReviewMetadata(BaseModel):
|
||||
"that belongs in the scene field."
|
||||
),
|
||||
)
|
||||
title: str = Field(
|
||||
max_length=80,
|
||||
description="Under 10 words. Name the apparent purpose or outcome of the activity together with the location involved. Do not narrate or list the sequence of actions step by step.",
|
||||
)
|
||||
scene: str = Field(
|
||||
min_length=150,
|
||||
max_length=600,
|
||||
description="A chronological narrative of what happens from start to finish, drawing directly from the items in observations.",
|
||||
)
|
||||
title: str = Field(
|
||||
max_length=80,
|
||||
description="Under 10 words. Name the apparent purpose or outcome of the activity together with the location involved. Do not narrate or list the sequence of actions step by step.",
|
||||
)
|
||||
shortSummary: str = Field(
|
||||
min_length=70,
|
||||
max_length=120,
|
||||
|
||||
@@ -229,10 +229,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
logger.debug(f"No person box available for {id}")
|
||||
return
|
||||
|
||||
# YuNet (cv2.FaceDetectorYN) is trained on BGR
|
||||
bgr = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
|
||||
left, top, right, bottom = person_box
|
||||
person = bgr[top:bottom, left:right]
|
||||
person = rgb[top:bottom, left:right]
|
||||
face_box = self.__detect_face(person, self.face_config.detection_threshold)
|
||||
|
||||
if not face_box:
|
||||
@@ -251,6 +250,11 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to convert face frame color for {id}: {e}")
|
||||
return
|
||||
else:
|
||||
# don't run for object without attributes
|
||||
if not obj_data.get("current_attributes"):
|
||||
|
||||
+187
-83
@@ -1,13 +1,9 @@
|
||||
"""Debug replay camera management for replaying recordings with detection overlays.
|
||||
|
||||
The startup work (ffmpeg concat + camera config publish) lives in
|
||||
frigate.jobs.debug_replay. This module owns only session presence
|
||||
(active), session metadata, and post-session cleanup.
|
||||
"""
|
||||
"""Debug replay camera management for replaying recordings with detection overlays."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess as sp
|
||||
import threading
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
@@ -25,7 +21,7 @@ from frigate.const import (
|
||||
REPLAY_DIR,
|
||||
THUMB_DIR,
|
||||
)
|
||||
from frigate.jobs.debug_replay import cancel_debug_replay_job, wait_for_runner
|
||||
from frigate.models import Recordings
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
@@ -33,14 +29,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DebugReplayManager:
|
||||
"""Owns the lifecycle pointers for a single debug replay session.
|
||||
|
||||
A session exists from the moment mark_starting is called (synchronously,
|
||||
inside the API handler) until clear_session runs (on success cleanup,
|
||||
failure, or stop). The active property is the source of truth that the
|
||||
status bar consumes — broader than the startup job, which only covers the
|
||||
preparing_clip / starting_camera window.
|
||||
"""
|
||||
"""Manages a single debug replay session."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
@@ -52,66 +41,144 @@ class DebugReplayManager:
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""True from mark_starting until clear_session."""
|
||||
"""Whether a replay session is currently active."""
|
||||
return self.replay_camera_name is not None
|
||||
|
||||
def mark_starting(
|
||||
def start(
|
||||
self,
|
||||
source_camera: str,
|
||||
replay_camera_name: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
) -> None:
|
||||
"""Synchronously claim the session before the job runner starts.
|
||||
|
||||
Called inside the API handler so the status bar sees active=True
|
||||
immediately, before the worker thread does any ffmpeg work.
|
||||
"""
|
||||
with self._lock:
|
||||
self.replay_camera_name = replay_camera_name
|
||||
self.source_camera = source_camera
|
||||
self.start_ts = start_ts
|
||||
self.end_ts = end_ts
|
||||
self.clip_path = None
|
||||
|
||||
def mark_session_ready(self, clip_path: str) -> None:
|
||||
"""Record the on-disk clip path after the camera has been published."""
|
||||
with self._lock:
|
||||
self.clip_path = clip_path
|
||||
|
||||
def clear_session(self) -> None:
|
||||
"""Reset session pointers without publishing camera removal.
|
||||
|
||||
Used by the job runner on failure paths. stop() does the camera
|
||||
teardown plus this clear in one step.
|
||||
"""
|
||||
with self._lock:
|
||||
self._clear_locked()
|
||||
|
||||
def _clear_locked(self) -> None:
|
||||
self.replay_camera_name = None
|
||||
self.source_camera = None
|
||||
self.clip_path = None
|
||||
self.start_ts = None
|
||||
self.end_ts = None
|
||||
|
||||
def publish_camera(
|
||||
self,
|
||||
source_camera: str,
|
||||
replay_name: str,
|
||||
clip_path: str,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
"""Build the in-memory replay camera config and publish the add event.
|
||||
) -> str:
|
||||
"""Start a debug replay session.
|
||||
|
||||
Called by the job runner during the starting_camera phase.
|
||||
Args:
|
||||
source_camera: Name of the source camera to replay
|
||||
start_ts: Start timestamp
|
||||
end_ts: End timestamp
|
||||
frigate_config: Current Frigate configuration
|
||||
config_publisher: Publisher for camera config updates
|
||||
|
||||
Returns:
|
||||
The replay camera name
|
||||
|
||||
Raises:
|
||||
ValueError: If a session is already active or parameters are invalid
|
||||
RuntimeError: If clip generation fails
|
||||
"""
|
||||
with self._lock:
|
||||
return self._start_locked(
|
||||
source_camera, start_ts, end_ts, frigate_config, config_publisher
|
||||
)
|
||||
|
||||
def _start_locked(
|
||||
self,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> str:
|
||||
if self.active:
|
||||
raise ValueError("A replay session is already active")
|
||||
|
||||
if source_camera not in frigate_config.cameras:
|
||||
raise ValueError(f"Camera '{source_camera}' not found")
|
||||
|
||||
if end_ts <= start_ts:
|
||||
raise ValueError("End time must be after start time")
|
||||
|
||||
# Query recordings for the source camera in the time range
|
||||
recordings = (
|
||||
Recordings.select(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
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 == source_camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
|
||||
if not recordings.count():
|
||||
raise ValueError(
|
||||
f"No recordings found for camera '{source_camera}' in the specified time range"
|
||||
)
|
||||
|
||||
# Create replay directory
|
||||
os.makedirs(REPLAY_DIR, exist_ok=True)
|
||||
|
||||
# Generate replay camera name
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
||||
|
||||
# Build concat file for ffmpeg
|
||||
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
||||
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
||||
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
|
||||
# Concatenate recordings into a single clip with -c copy (fast)
|
||||
ffmpeg_cmd = [
|
||||
frigate_config.ffmpeg.ffmpeg_path,
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
clip_path,
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"Generating replay clip for %s (%.1f - %.1f)",
|
||||
source_camera,
|
||||
start_ts,
|
||||
end_ts,
|
||||
)
|
||||
|
||||
try:
|
||||
result = sp.run(
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("FFmpeg error: %s", result.stderr)
|
||||
raise RuntimeError(
|
||||
f"Failed to generate replay clip: {result.stderr[-500:]}"
|
||||
)
|
||||
except sp.TimeoutExpired:
|
||||
raise RuntimeError("Clip generation timed out")
|
||||
finally:
|
||||
# Clean up concat file
|
||||
if os.path.exists(concat_file):
|
||||
os.remove(concat_file)
|
||||
|
||||
if not os.path.exists(clip_path):
|
||||
raise RuntimeError("Clip file was not created")
|
||||
|
||||
# Build camera config dict for the replay camera
|
||||
source_config = frigate_config.cameras[source_camera]
|
||||
camera_dict = self._build_camera_config_dict(
|
||||
source_config, replay_name, clip_path
|
||||
)
|
||||
|
||||
# Build an in-memory config with the replay camera added
|
||||
config_file = find_config_file()
|
||||
yaml_parser = YAML()
|
||||
with open(config_file, "r") as f:
|
||||
@@ -124,48 +191,75 @@ class DebugReplayManager:
|
||||
try:
|
||||
new_config = FrigateConfig.parse_object(config_data)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to validate replay camera config: {e}") from e
|
||||
raise RuntimeError(f"Failed to validate replay camera config: {e}")
|
||||
|
||||
# Update the running config
|
||||
frigate_config.cameras[replay_name] = new_config.cameras[replay_name]
|
||||
|
||||
# Publish the add event
|
||||
config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.add, replay_name),
|
||||
new_config.cameras[replay_name],
|
||||
)
|
||||
|
||||
# Store session state
|
||||
self.replay_camera_name = replay_name
|
||||
self.source_camera = source_camera
|
||||
self.clip_path = clip_path
|
||||
self.start_ts = start_ts
|
||||
self.end_ts = end_ts
|
||||
|
||||
logger.info("Debug replay started: %s -> %s", source_camera, replay_name)
|
||||
return replay_name
|
||||
|
||||
def stop(
|
||||
self,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
"""Cancel any in-flight startup job and tear down the active session.
|
||||
"""Stop the active replay session and clean up all artifacts.
|
||||
|
||||
Safe to call when no session is active (no-op with a warning).
|
||||
Args:
|
||||
frigate_config: Current Frigate configuration
|
||||
config_publisher: Publisher for camera config updates
|
||||
"""
|
||||
cancel_debug_replay_job()
|
||||
wait_for_runner(timeout=2.0)
|
||||
|
||||
with self._lock:
|
||||
if not self.active:
|
||||
logger.warning("No active replay session to stop")
|
||||
return
|
||||
self._stop_locked(frigate_config, config_publisher)
|
||||
|
||||
replay_name = self.replay_camera_name
|
||||
def _stop_locked(
|
||||
self,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
if not self.active:
|
||||
logger.warning("No active replay session to stop")
|
||||
return
|
||||
|
||||
# Only publish remove if the camera was actually added to the live
|
||||
# config (i.e. the runner reached the starting_camera phase).
|
||||
if replay_name is not None and replay_name in frigate_config.cameras:
|
||||
config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
|
||||
frigate_config.cameras[replay_name],
|
||||
)
|
||||
replay_name = self.replay_camera_name
|
||||
|
||||
if replay_name is not None:
|
||||
self._cleanup_db(replay_name)
|
||||
self._cleanup_files(replay_name)
|
||||
# Publish remove event so subscribers stop and remove from their config
|
||||
if replay_name in frigate_config.cameras:
|
||||
config_publisher.publish_update(
|
||||
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, replay_name),
|
||||
frigate_config.cameras[replay_name],
|
||||
)
|
||||
# Do NOT pop here — let subscribers handle removal from the shared
|
||||
# config dict when they process the ZMQ message to avoid race conditions
|
||||
|
||||
self._clear_locked()
|
||||
# Defensive DB cleanup
|
||||
self._cleanup_db(replay_name)
|
||||
|
||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||
# Remove filesystem artifacts
|
||||
self._cleanup_files(replay_name)
|
||||
|
||||
# Reset state
|
||||
self.replay_camera_name = None
|
||||
self.source_camera = None
|
||||
self.clip_path = None
|
||||
self.start_ts = None
|
||||
self.end_ts = None
|
||||
|
||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||
|
||||
def _build_camera_config_dict(
|
||||
self,
|
||||
@@ -173,7 +267,16 @@ class DebugReplayManager:
|
||||
replay_name: str,
|
||||
clip_path: str,
|
||||
) -> dict:
|
||||
"""Build a camera config dictionary for the replay camera."""
|
||||
"""Build a camera config dictionary for the replay camera.
|
||||
|
||||
Args:
|
||||
source_config: Source camera's CameraConfig
|
||||
replay_name: Name for the replay camera
|
||||
clip_path: Path to the replay clip file
|
||||
|
||||
Returns:
|
||||
Camera config as a dictionary
|
||||
"""
|
||||
# Extract detect config (exclude computed fields)
|
||||
detect_dict = source_config.detect.model_dump(
|
||||
exclude={"min_initialized", "max_disappeared", "enabled_in_config"}
|
||||
@@ -208,6 +311,7 @@ class DebugReplayManager:
|
||||
zone_dump = zone_config.model_dump(
|
||||
exclude={"contour", "color"}, exclude_defaults=True
|
||||
)
|
||||
# Always include required fields
|
||||
zone_dump.setdefault("coordinates", zone_config.coordinates)
|
||||
zones_dict[zone_name] = zone_dump
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ class ONNXModelRunner(BaseModelRunner):
|
||||
return model_type in [
|
||||
EnrichmentModelTypeEnum.paddleocr.value,
|
||||
EnrichmentModelTypeEnum.jina_v2.value,
|
||||
EnrichmentModelTypeEnum.arcface.value,
|
||||
ModelTypeEnum.rfdetr.value,
|
||||
ModelTypeEnum.dfine.value,
|
||||
]
|
||||
|
||||
@@ -52,12 +52,6 @@ class OvDetector(DetectionApi):
|
||||
self.h = detector_config.model.height
|
||||
self.w = detector_config.model.width
|
||||
|
||||
logger.info(
|
||||
"Loading OpenVINO model %s on device %s",
|
||||
detector_config.model.path,
|
||||
detector_config.device,
|
||||
)
|
||||
|
||||
self.runner = OpenVINOModelRunner(
|
||||
model_path=detector_config.model.path,
|
||||
device=detector_config.device,
|
||||
|
||||
@@ -109,7 +109,7 @@ When forming your description:
|
||||
|
||||
Respond with a JSON object matching the provided schema. Field-specific guidance:
|
||||
- `scene`: Describe how the sequence begins, then the progression of events — all significant movements and actions in order. For example, if a vehicle arrives and then a person exits, describe both sequentially. For named subjects (those with a `←` separator in "Objects in Scene"), always use their name — do not replace them with generic terms. For unnamed objects (e.g., "person", "car"), refer to them naturally with articles (e.g., "a person", "the car"). Your description should align with and support the threat level you assign.
|
||||
- `title`: Name the primary activity across the observations, together with the location. An activity is what is being done with objects, tools, or surfaces; locomotion through the scene qualifies as the activity only when no other interaction is observed. For named subjects, always use their name. For unnamed objects, refer to them naturally with articles. No editorial qualifiers like "routine" or "suspicious."
|
||||
- `title`: Characterize **what took place and where** — interpret the overall purpose or outcome, do not simply compress the scene description into fewer words. Include the relevant location (zone, area, or entry point). For named subjects, always use their name. For unnamed objects, refer to them naturally with articles. No editorial qualifiers like "routine" or "suspicious."
|
||||
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
|
||||
{get_concern_prompt()}
|
||||
|
||||
|
||||
+4
-58
@@ -1,7 +1,5 @@
|
||||
"""Ollama Provider for Frigate AI."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
@@ -18,41 +16,6 @@ from frigate.genai.utils import parse_tool_calls_from_message
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_multimodal_content(
|
||||
content: Any,
|
||||
) -> tuple[Optional[str], Optional[list[bytes]]]:
|
||||
"""Convert OpenAI-style multimodal content to Ollama's (text, images) shape.
|
||||
|
||||
The chat API constructs user messages with content as a list of
|
||||
``{"type": "text"}`` and ``{"type": "image_url"}`` parts when a tool
|
||||
returns a live frame. Ollama's SDK requires content to be a string and
|
||||
images to be passed in a separate field, so we extract each.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return content, None
|
||||
|
||||
text_parts: list[str] = []
|
||||
images: list[bytes] = []
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
part_type = part.get("type")
|
||||
if part_type == "text":
|
||||
text = part.get("text")
|
||||
if text:
|
||||
text_parts.append(str(text))
|
||||
elif part_type == "image_url":
|
||||
url = (part.get("image_url") or {}).get("url", "")
|
||||
if isinstance(url, str) and url.startswith("data:"):
|
||||
try:
|
||||
encoded = url.split(",", 1)[1]
|
||||
images.append(base64.b64decode(encoded, validate=True))
|
||||
except (ValueError, IndexError, binascii.Error) as e:
|
||||
logger.debug("Failed to decode multimodal image url: %s", e)
|
||||
|
||||
return ("\n".join(text_parts) if text_parts else None), (images or None)
|
||||
|
||||
|
||||
@register_genai_provider(GenAIProviderEnum.ollama)
|
||||
class OllamaClient(GenAIClient):
|
||||
"""Generative AI client for Frigate using Ollama."""
|
||||
@@ -68,12 +31,6 @@ class OllamaClient(GenAIClient):
|
||||
provider: ApiClient | None
|
||||
provider_options: dict[str, Any]
|
||||
|
||||
def _auth_headers(self) -> dict | None:
|
||||
if self.genai_config.api_key:
|
||||
return {"Authorization": "Bearer " + self.genai_config.api_key}
|
||||
|
||||
return None
|
||||
|
||||
def _init_provider(self) -> ApiClient | None:
|
||||
"""Initialize the client."""
|
||||
self.provider_options = {
|
||||
@@ -82,11 +39,7 @@ class OllamaClient(GenAIClient):
|
||||
}
|
||||
|
||||
try:
|
||||
client = ApiClient(
|
||||
host=self.genai_config.base_url,
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout)
|
||||
# ensure the model is available locally
|
||||
response = client.show(self.genai_config.model)
|
||||
if response.get("error"):
|
||||
@@ -213,9 +166,7 @@ class OllamaClient(GenAIClient):
|
||||
return []
|
||||
try:
|
||||
client = ApiClient(
|
||||
host=self.genai_config.base_url,
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
host=self.genai_config.base_url, timeout=self.timeout
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
@@ -244,13 +195,10 @@ class OllamaClient(GenAIClient):
|
||||
"""Build request_messages and params for chat (sync or stream)."""
|
||||
request_messages = []
|
||||
for msg in messages:
|
||||
content, images = _normalize_multimodal_content(msg.get("content", ""))
|
||||
msg_dict: dict[str, Any] = {
|
||||
msg_dict = {
|
||||
"role": msg.get("role"),
|
||||
"content": content if content is not None else "",
|
||||
"content": msg.get("content", ""),
|
||||
}
|
||||
if images:
|
||||
msg_dict["images"] = images
|
||||
if msg.get("tool_call_id"):
|
||||
msg_dict["tool_call_id"] = msg["tool_call_id"]
|
||||
if msg.get("name"):
|
||||
@@ -396,7 +344,6 @@ class OllamaClient(GenAIClient):
|
||||
async_client = OllamaAsyncClient(
|
||||
host=self.genai_config.base_url,
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
response = await async_client.chat(**request_params)
|
||||
result = self._message_from_response(response)
|
||||
@@ -412,7 +359,6 @@ class OllamaClient(GenAIClient):
|
||||
async_client = OllamaAsyncClient(
|
||||
host=self.genai_config.base_url,
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
content_parts: list[str] = []
|
||||
final_message: dict[str, Any] | None = None
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
"""Debug replay startup job: ffmpeg concat + camera config publish.
|
||||
|
||||
The runner orchestrates the async portion of starting a debug replay
|
||||
session. The DebugReplayManager (in frigate.debug_replay) owns session
|
||||
presence so the status bar can keep reading a single `active` flag from
|
||||
/debug_replay/status for the entire session window — which is broader
|
||||
than this job's lifetime.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Optional, cast
|
||||
|
||||
from peewee import ModelSelect
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX, REPLAY_DIR
|
||||
from frigate.jobs.export import JobStatePublisher
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.jobs.manager import job_is_running, set_current_job
|
||||
from frigate.models import Recordings
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Coalesce frequent ffmpeg progress callbacks so the WS isn't flooded.
|
||||
PROGRESS_BROADCAST_MIN_INTERVAL = 1.0
|
||||
|
||||
JOB_TYPE = "debug_replay"
|
||||
|
||||
STEP_PREPARING_CLIP = "preparing_clip"
|
||||
STEP_STARTING_CAMERA = "starting_camera"
|
||||
|
||||
|
||||
_active_runner: Optional["DebugReplayJobRunner"] = None
|
||||
_runner_lock = threading.Lock()
|
||||
|
||||
|
||||
def _set_active_runner(runner: Optional["DebugReplayJobRunner"]) -> None:
|
||||
global _active_runner
|
||||
with _runner_lock:
|
||||
_active_runner = runner
|
||||
|
||||
|
||||
def get_active_runner() -> Optional["DebugReplayJobRunner"]:
|
||||
with _runner_lock:
|
||||
return _active_runner
|
||||
|
||||
|
||||
@dataclass
|
||||
class DebugReplayJob(Job):
|
||||
"""Job state for a debug replay startup."""
|
||||
|
||||
job_type: str = JOB_TYPE
|
||||
source_camera: str = ""
|
||||
replay_camera_name: str = ""
|
||||
start_ts: float = 0.0
|
||||
end_ts: float = 0.0
|
||||
current_step: Optional[str] = None
|
||||
progress_percent: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Whitelisted payload for the job_state WS topic.
|
||||
|
||||
Replay-specific fields land in results so the frontend's
|
||||
generic Job<TResults> type can be parameterised cleanly.
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"job_type": self.job_type,
|
||||
"status": self.status,
|
||||
"start_time": self.start_time,
|
||||
"end_time": self.end_time,
|
||||
"error_message": self.error_message,
|
||||
"results": {
|
||||
"current_step": self.current_step,
|
||||
"progress_percent": self.progress_percent,
|
||||
"source_camera": self.source_camera,
|
||||
"replay_camera_name": self.replay_camera_name,
|
||||
"start_ts": self.start_ts,
|
||||
"end_ts": self.end_ts,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def query_recordings(source_camera: str, start_ts: float, end_ts: float) -> ModelSelect:
|
||||
"""Return the Recordings query for the time range.
|
||||
|
||||
Module-level so tests can patch it without instantiating a runner.
|
||||
"""
|
||||
query = (
|
||||
Recordings.select(
|
||||
Recordings.path,
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
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 == source_camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
return cast(ModelSelect, query)
|
||||
|
||||
|
||||
class DebugReplayJobRunner(threading.Thread):
|
||||
"""Worker thread that drives the startup job to completion.
|
||||
|
||||
Owns the live ffmpeg Popen reference for cancellation. Cancellation
|
||||
is two-step (threading.Event + proc.terminate()) so the runner
|
||||
both knows it should stop and is unblocked from its blocking subprocess
|
||||
wait.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
job: DebugReplayJob,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
replay_manager: "DebugReplayManager",
|
||||
publisher: Optional[JobStatePublisher] = None,
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name=f"debug_replay_{job.id}")
|
||||
self.job = job
|
||||
self.frigate_config = frigate_config
|
||||
self.config_publisher = config_publisher
|
||||
self.replay_manager = replay_manager
|
||||
self.publisher = publisher if publisher is not None else JobStatePublisher()
|
||||
self._cancel_event = threading.Event()
|
||||
self._active_process: sp.Popen | None = None
|
||||
self._proc_lock = threading.Lock()
|
||||
self._last_broadcast_monotonic: float = 0.0
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Request cancellation. Idempotent."""
|
||||
self._cancel_event.set()
|
||||
with self._proc_lock:
|
||||
proc = self._active_process
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to terminate ffmpeg subprocess: %s", exc)
|
||||
|
||||
def is_cancelled(self) -> bool:
|
||||
return self._cancel_event.is_set()
|
||||
|
||||
def _record_proc(self, proc: sp.Popen) -> None:
|
||||
with self._proc_lock:
|
||||
self._active_process = proc
|
||||
# Race: cancel arrived between Popen and _record_proc.
|
||||
if self._cancel_event.is_set():
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _broadcast(self, force: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
not force
|
||||
and now - self._last_broadcast_monotonic < PROGRESS_BROADCAST_MIN_INTERVAL
|
||||
):
|
||||
return
|
||||
self._last_broadcast_monotonic = now
|
||||
|
||||
try:
|
||||
self.publisher.publish(self.job.to_dict())
|
||||
except Exception as err:
|
||||
logger.warning("Publisher raised during job state broadcast: %s", err)
|
||||
|
||||
def run(self) -> None:
|
||||
replay_name = self.job.replay_camera_name
|
||||
os.makedirs(REPLAY_DIR, exist_ok=True)
|
||||
concat_file = os.path.join(REPLAY_DIR, f"{replay_name}_concat.txt")
|
||||
clip_path = os.path.join(REPLAY_DIR, f"{replay_name}.mp4")
|
||||
|
||||
self.job.status = JobStatusTypesEnum.running
|
||||
self.job.start_time = time.time()
|
||||
self.job.current_step = STEP_PREPARING_CLIP
|
||||
self._broadcast(force=True)
|
||||
|
||||
try:
|
||||
recordings = query_recordings(
|
||||
self.job.source_camera, self.job.start_ts, self.job.end_ts
|
||||
)
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
|
||||
ffmpeg_cmd = [
|
||||
self.frigate_config.ffmpeg.ffmpeg_path,
|
||||
"-hide_banner",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
clip_path,
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"Generating replay clip for %s (%.1f - %.1f)",
|
||||
self.job.source_camera,
|
||||
self.job.start_ts,
|
||||
self.job.end_ts,
|
||||
)
|
||||
|
||||
def _on_progress(percent: float) -> None:
|
||||
self.job.progress_percent = percent
|
||||
self._broadcast()
|
||||
|
||||
try:
|
||||
returncode, stderr = run_ffmpeg_with_progress(
|
||||
ffmpeg_cmd,
|
||||
expected_duration_seconds=max(
|
||||
0.0, self.job.end_ts - self.job.start_ts
|
||||
),
|
||||
on_progress=_on_progress,
|
||||
process_started=self._record_proc,
|
||||
use_low_priority=True,
|
||||
)
|
||||
finally:
|
||||
with self._proc_lock:
|
||||
self._active_process = None
|
||||
|
||||
if self._cancel_event.is_set():
|
||||
self._finalize_cancelled(clip_path)
|
||||
return
|
||||
|
||||
if returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg failed: {stderr[-500:]}")
|
||||
|
||||
if not os.path.exists(clip_path):
|
||||
raise RuntimeError("Clip file was not created")
|
||||
|
||||
self.job.current_step = STEP_STARTING_CAMERA
|
||||
self.job.progress_percent = 100.0
|
||||
self._broadcast(force=True)
|
||||
|
||||
if self._cancel_event.is_set():
|
||||
self._finalize_cancelled(clip_path)
|
||||
return
|
||||
|
||||
self.replay_manager.publish_camera(
|
||||
source_camera=self.job.source_camera,
|
||||
replay_name=replay_name,
|
||||
clip_path=clip_path,
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.config_publisher,
|
||||
)
|
||||
self.replay_manager.mark_session_ready(clip_path)
|
||||
|
||||
self.job.status = JobStatusTypesEnum.success
|
||||
self.job.end_time = time.time()
|
||||
self._broadcast(force=True)
|
||||
logger.info(
|
||||
"Debug replay started: %s -> %s",
|
||||
self.job.source_camera,
|
||||
replay_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Debug replay startup failed")
|
||||
self.job.status = JobStatusTypesEnum.failed
|
||||
self.job.error_message = str(exc)
|
||||
self.job.end_time = time.time()
|
||||
self._broadcast(force=True)
|
||||
self.replay_manager.clear_session()
|
||||
_remove_silent(clip_path)
|
||||
finally:
|
||||
_remove_silent(concat_file)
|
||||
_set_active_runner(None)
|
||||
|
||||
def _finalize_cancelled(self, clip_path: str) -> None:
|
||||
logger.info("Debug replay startup cancelled")
|
||||
self.job.status = JobStatusTypesEnum.cancelled
|
||||
self.job.end_time = time.time()
|
||||
self._broadcast(force=True)
|
||||
# The caller of cancel_debug_replay_job (DebugReplayManager.stop) owns
|
||||
# session cleanup — db rows, filesystem artifacts, clear_session. We
|
||||
# only clean up the partial concat output we created.
|
||||
_remove_silent(clip_path)
|
||||
|
||||
|
||||
def _remove_silent(path: str) -> None:
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def start_debug_replay_job(
|
||||
*,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
replay_manager: "DebugReplayManager",
|
||||
) -> str:
|
||||
"""Validate, create job, start runner. Returns the job id.
|
||||
|
||||
Raises ValueError for bad params (camera missing, time range
|
||||
invalid, no recordings) and RuntimeError if a session is already
|
||||
active.
|
||||
"""
|
||||
if job_is_running(JOB_TYPE) or replay_manager.active:
|
||||
raise RuntimeError("A replay session is already active")
|
||||
|
||||
if source_camera not in frigate_config.cameras:
|
||||
raise ValueError(f"Camera '{source_camera}' not found")
|
||||
|
||||
if end_ts <= start_ts:
|
||||
raise ValueError("End time must be after start time")
|
||||
|
||||
recordings = query_recordings(source_camera, start_ts, end_ts)
|
||||
if not recordings.count():
|
||||
raise ValueError(
|
||||
f"No recordings found for camera '{source_camera}' in the specified time range"
|
||||
)
|
||||
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{source_camera}"
|
||||
replay_manager.mark_starting(
|
||||
source_camera=source_camera,
|
||||
replay_camera_name=replay_name,
|
||||
start_ts=start_ts,
|
||||
end_ts=end_ts,
|
||||
)
|
||||
|
||||
job = DebugReplayJob(
|
||||
source_camera=source_camera,
|
||||
replay_camera_name=replay_name,
|
||||
start_ts=start_ts,
|
||||
end_ts=end_ts,
|
||||
)
|
||||
set_current_job(job)
|
||||
|
||||
runner = DebugReplayJobRunner(
|
||||
job=job,
|
||||
frigate_config=frigate_config,
|
||||
config_publisher=config_publisher,
|
||||
replay_manager=replay_manager,
|
||||
)
|
||||
_set_active_runner(runner)
|
||||
runner.start()
|
||||
|
||||
return job.id
|
||||
|
||||
|
||||
def cancel_debug_replay_job() -> bool:
|
||||
"""Signal the active runner to cancel.
|
||||
|
||||
Returns True if a runner was signalled, False if no job was active.
|
||||
"""
|
||||
runner = get_active_runner()
|
||||
if runner is None:
|
||||
return False
|
||||
runner.cancel()
|
||||
return True
|
||||
|
||||
|
||||
def wait_for_runner(timeout: float = 2.0) -> bool:
|
||||
"""Join the active runner. Returns True if the runner ended in time."""
|
||||
runner = get_active_runner()
|
||||
if runner is None:
|
||||
return True
|
||||
runner.join(timeout=timeout)
|
||||
return not runner.is_alive()
|
||||
@@ -349,13 +349,6 @@ def move_preview_frames(loc: str) -> None:
|
||||
if not os.path.exists(preview_holdover):
|
||||
return
|
||||
|
||||
if not os.access(preview_holdover, os.R_OK | os.W_OK):
|
||||
logger.error(
|
||||
"Insufficient permissions on preview restart cache at %s",
|
||||
preview_holdover,
|
||||
)
|
||||
return
|
||||
|
||||
shutil.move(preview_holdover, preview_cache)
|
||||
except shutil.Error:
|
||||
logger.error("Failed to restore preview cache.")
|
||||
|
||||
@@ -361,17 +361,14 @@ class PreviewRecorder:
|
||||
small_frame,
|
||||
cv2.COLOR_YUV2BGR_I420,
|
||||
)
|
||||
cache_path = get_cache_image_name(self.camera_name, frame_time)
|
||||
|
||||
if not cv2.imwrite(
|
||||
cache_path,
|
||||
cv2.imwrite(
|
||||
get_cache_image_name(self.camera_name, frame_time),
|
||||
small_frame,
|
||||
[
|
||||
int(cv2.IMWRITE_WEBP_QUALITY),
|
||||
PREVIEW_QUALITY_WEBP[self.config.record.preview.quality],
|
||||
],
|
||||
):
|
||||
logger.error("Failed to write preview frame to %s", cache_path)
|
||||
)
|
||||
|
||||
def write_data(
|
||||
self,
|
||||
|
||||
@@ -351,11 +351,9 @@ class RecordingCleanup(threading.Thread):
|
||||
)
|
||||
.where(
|
||||
ReviewSegment.camera == camera,
|
||||
# candidate recordings can extend up to continuous_expire_date
|
||||
# (the no-motion no-audio branch of the recordings query),
|
||||
# so reviews must cover that full range to avoid deleting
|
||||
# segments that overlap recent alerts/detections.
|
||||
ReviewSegment.start_time < continuous_expire_date,
|
||||
# need to ensure segments for all reviews starting
|
||||
# before the expire date are included
|
||||
ReviewSegment.start_time < motion_expire_date,
|
||||
)
|
||||
.order_by(ReviewSegment.start_time)
|
||||
.namedtuples()
|
||||
|
||||
+93
-46
@@ -13,7 +13,6 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
from peewee import DoesNotExist
|
||||
|
||||
from frigate.config import FfmpegConfig, FrigateConfig
|
||||
@@ -23,13 +22,13 @@ from frigate.const import (
|
||||
EXPORT_DIR,
|
||||
MAX_PLAYLIST_SECONDS,
|
||||
PREVIEW_FRAME_TYPE,
|
||||
PROCESS_PRIORITY_LOW,
|
||||
)
|
||||
from frigate.ffmpeg_presets import (
|
||||
EncodeTypeEnum,
|
||||
parse_preset_hardware_acceleration_encode,
|
||||
)
|
||||
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||
from frigate.util.time import is_current_hour
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -243,43 +242,109 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
return total
|
||||
|
||||
def _inject_progress_flags(self, ffmpeg_cmd: list[str]) -> list[str]:
|
||||
"""Insert FFmpeg progress reporting flags before the output path.
|
||||
|
||||
``-progress pipe:2`` writes structured key=value lines to stderr,
|
||||
``-nostats`` suppresses the noisy default stats output.
|
||||
"""
|
||||
if not ffmpeg_cmd:
|
||||
return ffmpeg_cmd
|
||||
return ffmpeg_cmd[:-1] + ["-progress", "pipe:2", "-nostats", ffmpeg_cmd[-1]]
|
||||
|
||||
def _run_ffmpeg_with_progress(
|
||||
self,
|
||||
ffmpeg_cmd: list[str],
|
||||
playlist_lines: str | list[str],
|
||||
step: str = "encoding",
|
||||
) -> tuple[int, str]:
|
||||
"""Delegate to the shared helper, mapping percent → (step, percent).
|
||||
"""Run an FFmpeg export command, parsing progress events from stderr.
|
||||
|
||||
Returns ``(returncode, captured_stderr)``.
|
||||
Returns ``(returncode, captured_stderr)``. Stdout is left attached to
|
||||
the parent process so we don't have to drain it (and risk a deadlock
|
||||
if the buffer fills). Progress percent is computed against the
|
||||
expected output duration; values are clamped to [0, 100] inside
|
||||
:py:meth:`_emit_progress`.
|
||||
"""
|
||||
cmd = ["nice", "-n", str(PROCESS_PRIORITY_LOW)] + self._inject_progress_flags(
|
||||
ffmpeg_cmd
|
||||
)
|
||||
|
||||
if isinstance(playlist_lines, list):
|
||||
stdin_payload = "\n".join(playlist_lines)
|
||||
else:
|
||||
stdin_payload = playlist_lines
|
||||
|
||||
return run_ffmpeg_with_progress(
|
||||
ffmpeg_cmd,
|
||||
expected_duration_seconds=self._expected_output_duration_seconds(),
|
||||
on_progress=lambda percent: self._emit_progress(step, percent),
|
||||
stdin_payload=stdin_payload,
|
||||
use_low_priority=True,
|
||||
expected_duration = self._expected_output_duration_seconds()
|
||||
|
||||
self._emit_progress(step, 0.0)
|
||||
|
||||
proc = sp.Popen(
|
||||
cmd,
|
||||
stdin=sp.PIPE,
|
||||
stderr=sp.PIPE,
|
||||
text=True,
|
||||
encoding="ascii",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
||||
# return in iso format using the configured ui.timezone when set,
|
||||
# so the auto-generated export name reflects local time rather
|
||||
# than the container's UTC clock
|
||||
tz_name = self.config.ui.timezone
|
||||
if tz_name:
|
||||
assert proc.stdin is not None
|
||||
assert proc.stderr is not None
|
||||
|
||||
try:
|
||||
proc.stdin.write(stdin_payload)
|
||||
except (BrokenPipeError, OSError):
|
||||
# FFmpeg may have rejected the input early; still wait for it
|
||||
# to terminate so the returncode is meaningful.
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
tz = pytz.timezone(tz_name)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
tz = None
|
||||
if tz is not None:
|
||||
return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
proc.stdin.close()
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
try:
|
||||
for raw_line in proc.stderr:
|
||||
captured.append(raw_line)
|
||||
line = raw_line.strip()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith("out_time_us="):
|
||||
if expected_duration <= 0:
|
||||
continue
|
||||
try:
|
||||
out_time_us = int(line.split("=", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if out_time_us < 0:
|
||||
continue
|
||||
out_seconds = out_time_us / 1_000_000.0
|
||||
percent = (out_seconds / expected_duration) * 100.0
|
||||
self._emit_progress(step, percent)
|
||||
elif line == "progress=end":
|
||||
self._emit_progress(step, 100.0)
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Failed reading FFmpeg progress for %s", self.export_id)
|
||||
|
||||
proc.wait()
|
||||
|
||||
# Drain any remaining stderr so callers can log it on failure.
|
||||
try:
|
||||
remaining = proc.stderr.read()
|
||||
if remaining:
|
||||
captured.append(remaining)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return proc.returncode, "".join(captured)
|
||||
|
||||
def get_datetime_from_timestamp(self, timestamp: int) -> str:
|
||||
# return in iso format
|
||||
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _chapter_metadata_path(self) -> str:
|
||||
@@ -342,7 +407,6 @@ class RecordingExporter(threading.Thread):
|
||||
return None
|
||||
|
||||
total_output = windows[-1][2] + (windows[-1][1] - windows[-1][0])
|
||||
last_recorded_end = windows[-1][1]
|
||||
|
||||
def wall_to_output(t: float) -> float:
|
||||
t = max(float(self.start_time), min(float(self.end_time), t))
|
||||
@@ -355,18 +419,8 @@ class RecordingExporter(threading.Thread):
|
||||
|
||||
chapter_blocks: list[str] = []
|
||||
for review in review_rows:
|
||||
if review.start_time is None:
|
||||
continue
|
||||
# In-progress segments have a NULL end_time until the activity
|
||||
# closes; clamp to the last recorded second so the chapter never
|
||||
# extends past the actual video.
|
||||
review_end = (
|
||||
float(review.end_time)
|
||||
if review.end_time is not None
|
||||
else last_recorded_end
|
||||
)
|
||||
start_out = wall_to_output(float(review.start_time))
|
||||
end_out = wall_to_output(review_end)
|
||||
end_out = wall_to_output(float(review.end_time))
|
||||
|
||||
# Drop chapters that fall entirely in a recording gap, or are
|
||||
# too short to be navigable in a player.
|
||||
@@ -449,14 +503,16 @@ class RecordingExporter(threading.Thread):
|
||||
except DoesNotExist:
|
||||
return ""
|
||||
|
||||
diff = max(0.0, float(self.start_time) - float(preview.start_time))
|
||||
diff = self.start_time - preview.start_time
|
||||
minutes = int(diff / 60)
|
||||
seconds = int(diff % 60)
|
||||
ffmpeg_cmd = [
|
||||
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-ss",
|
||||
f"{diff:.3f}",
|
||||
f"00:{minutes}:{seconds}",
|
||||
"-i",
|
||||
preview.path,
|
||||
"-frames",
|
||||
@@ -482,18 +538,12 @@ class RecordingExporter(threading.Thread):
|
||||
start_file = f"{file_start}{self.start_time}.{PREVIEW_FRAME_TYPE}"
|
||||
end_file = f"{file_start}{self.end_time}.{PREVIEW_FRAME_TYPE}"
|
||||
selected_preview = None
|
||||
# Preview frames are written at most 1-2 fps during activity
|
||||
# and as little as one every 30s during quiet periods, so a
|
||||
# short export window can contain zero frames. Track the most
|
||||
# recent frame before the window as a fallback.
|
||||
fallback_preview = None
|
||||
|
||||
for file in sorted(os.listdir(preview_dir)):
|
||||
if not file.startswith(file_start):
|
||||
continue
|
||||
|
||||
if file < start_file:
|
||||
fallback_preview = os.path.join(preview_dir, file)
|
||||
continue
|
||||
|
||||
if file > end_file:
|
||||
@@ -502,9 +552,6 @@ class RecordingExporter(threading.Thread):
|
||||
selected_preview = os.path.join(preview_dir, file)
|
||||
break
|
||||
|
||||
if not selected_preview:
|
||||
selected_preview = fallback_preview
|
||||
|
||||
if not selected_preview:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Tests for /debug_replay API endpoints."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestDebugReplayAPI(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Event, Recordings, ReviewSegment])
|
||||
self.app = self.create_app()
|
||||
|
||||
def test_start_returns_202_with_job_id(self):
|
||||
# Stub the factory to skip validation/threading and just record the
|
||||
# name on the manager the way the real factory's mark_starting would.
|
||||
def fake_start(**kwargs):
|
||||
kwargs["replay_manager"].mark_starting(
|
||||
source_camera=kwargs["source_camera"],
|
||||
replay_camera_name="_replay_front",
|
||||
start_ts=kwargs["start_ts"],
|
||||
end_ts=kwargs["end_ts"],
|
||||
)
|
||||
return "job-1234"
|
||||
|
||||
with patch(
|
||||
"frigate.api.debug_replay.start_debug_replay_job",
|
||||
side_effect=fake_start,
|
||||
):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.post(
|
||||
"/debug_replay/start",
|
||||
json={
|
||||
"camera": "front",
|
||||
"start_time": 100,
|
||||
"end_time": 200,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
body = resp.json()
|
||||
self.assertTrue(body["success"])
|
||||
self.assertEqual(body["job_id"], "job-1234")
|
||||
self.assertEqual(body["replay_camera"], "_replay_front")
|
||||
|
||||
def test_start_returns_400_on_validation_error(self):
|
||||
with patch(
|
||||
"frigate.api.debug_replay.start_debug_replay_job",
|
||||
side_effect=ValueError("Camera 'missing' not found"),
|
||||
):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.post(
|
||||
"/debug_replay/start",
|
||||
json={
|
||||
"camera": "missing",
|
||||
"start_time": 100,
|
||||
"end_time": 200,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
body = resp.json()
|
||||
self.assertFalse(body["success"])
|
||||
# Message is hard-coded so we don't echo exception text back to clients
|
||||
# (CodeQL: information exposure through an exception).
|
||||
self.assertEqual(body["message"], "Invalid debug replay parameters")
|
||||
|
||||
def test_start_returns_409_when_session_already_active(self):
|
||||
with patch(
|
||||
"frigate.api.debug_replay.start_debug_replay_job",
|
||||
side_effect=RuntimeError("A replay session is already active"),
|
||||
):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.post(
|
||||
"/debug_replay/start",
|
||||
json={
|
||||
"camera": "front",
|
||||
"start_time": 100,
|
||||
"end_time": 200,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 409)
|
||||
body = resp.json()
|
||||
self.assertFalse(body["success"])
|
||||
|
||||
def test_status_inactive_when_no_session(self):
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/debug_replay/status")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertFalse(body["active"])
|
||||
self.assertIsNone(body["replay_camera"])
|
||||
self.assertIsNone(body["source_camera"])
|
||||
self.assertIsNone(body["start_time"])
|
||||
self.assertIsNone(body["end_time"])
|
||||
self.assertFalse(body["live_ready"])
|
||||
# Make sure deprecated fields are gone
|
||||
self.assertNotIn("state", body)
|
||||
self.assertNotIn("progress_percent", body)
|
||||
self.assertNotIn("error_message", body)
|
||||
|
||||
def test_status_active_after_mark_starting(self):
|
||||
manager = self.app.replay_manager
|
||||
manager.mark_starting(
|
||||
source_camera="front",
|
||||
replay_camera_name="_replay_front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
)
|
||||
|
||||
with AuthTestClient(self.app) as client:
|
||||
resp = client.get("/debug_replay/status")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertTrue(body["active"])
|
||||
self.assertEqual(body["replay_camera"], "_replay_front")
|
||||
self.assertEqual(body["source_camera"], "front")
|
||||
self.assertEqual(body["start_time"], 100.0)
|
||||
self.assertEqual(body["end_time"], 200.0)
|
||||
self.assertFalse(body["live_ready"])
|
||||
@@ -64,9 +64,9 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
def test_config_class(self):
|
||||
frigate_config = FrigateConfig(**self.minimal)
|
||||
assert "ov" in frigate_config.detectors.keys()
|
||||
assert frigate_config.detectors["ov"].type == DetectorTypeEnum.openvino
|
||||
assert frigate_config.detectors["ov"].model.width == 300
|
||||
assert "cpu" in frigate_config.detectors.keys()
|
||||
assert frigate_config.detectors["cpu"].type == DetectorTypeEnum.cpu
|
||||
assert frigate_config.detectors["cpu"].model.width == 320
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_detector_custom_model_path(self, mock_labels):
|
||||
@@ -1005,7 +1005,6 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"detectors": {"cpu": {"type": "cpu"}},
|
||||
"model": {"path": "plus://test"},
|
||||
"cameras": {
|
||||
"back": {
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
"""Tests for the simplified DebugReplayManager.
|
||||
|
||||
Startup orchestration lives in ``frigate.jobs.debug_replay`` (covered by
|
||||
``test_debug_replay_job``). The manager owns only session presence and
|
||||
cleanup.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestDebugReplayManagerSession(unittest.TestCase):
|
||||
def test_inactive_by_default(self) -> None:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
manager = DebugReplayManager()
|
||||
|
||||
self.assertFalse(manager.active)
|
||||
self.assertIsNone(manager.replay_camera_name)
|
||||
self.assertIsNone(manager.source_camera)
|
||||
self.assertIsNone(manager.clip_path)
|
||||
self.assertIsNone(manager.start_ts)
|
||||
self.assertIsNone(manager.end_ts)
|
||||
|
||||
def test_mark_starting_sets_session_pointers_and_active(self) -> None:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
manager = DebugReplayManager()
|
||||
|
||||
manager.mark_starting(
|
||||
source_camera="front",
|
||||
replay_camera_name="_replay_front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
)
|
||||
|
||||
self.assertTrue(manager.active)
|
||||
self.assertEqual(manager.replay_camera_name, "_replay_front")
|
||||
self.assertEqual(manager.source_camera, "front")
|
||||
self.assertEqual(manager.start_ts, 100.0)
|
||||
self.assertEqual(manager.end_ts, 200.0)
|
||||
self.assertIsNone(manager.clip_path)
|
||||
|
||||
def test_mark_session_ready_sets_clip_path(self) -> None:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
manager = DebugReplayManager()
|
||||
manager.mark_starting("front", "_replay_front", 100.0, 200.0)
|
||||
|
||||
manager.mark_session_ready(clip_path="/tmp/replay/_replay_front.mp4")
|
||||
|
||||
self.assertEqual(manager.clip_path, "/tmp/replay/_replay_front.mp4")
|
||||
self.assertTrue(manager.active)
|
||||
|
||||
def test_clear_session_resets_all_pointers(self) -> None:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
manager = DebugReplayManager()
|
||||
manager.mark_starting("front", "_replay_front", 100.0, 200.0)
|
||||
manager.mark_session_ready("/tmp/replay/clip.mp4")
|
||||
|
||||
manager.clear_session()
|
||||
|
||||
self.assertFalse(manager.active)
|
||||
self.assertIsNone(manager.replay_camera_name)
|
||||
self.assertIsNone(manager.source_camera)
|
||||
self.assertIsNone(manager.clip_path)
|
||||
self.assertIsNone(manager.start_ts)
|
||||
self.assertIsNone(manager.end_ts)
|
||||
|
||||
|
||||
class TestDebugReplayManagerStop(unittest.TestCase):
|
||||
def test_stop_when_inactive_is_a_noop(self) -> None:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
manager = DebugReplayManager()
|
||||
frigate_config = MagicMock()
|
||||
frigate_config.cameras = {}
|
||||
publisher = MagicMock()
|
||||
|
||||
# Should not raise; should not publish any events.
|
||||
manager.stop(frigate_config=frigate_config, config_publisher=publisher)
|
||||
|
||||
publisher.publish_update.assert_not_called()
|
||||
|
||||
def test_stop_publishes_remove_when_camera_was_published(self) -> None:
|
||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
manager = DebugReplayManager()
|
||||
manager.mark_starting("front", "_replay_front", 100.0, 200.0)
|
||||
manager.mark_session_ready("/tmp/replay/_replay_front.mp4")
|
||||
|
||||
camera_config = MagicMock()
|
||||
frigate_config = MagicMock()
|
||||
frigate_config.cameras = {"_replay_front": camera_config}
|
||||
publisher = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(manager, "_cleanup_db"),
|
||||
patch.object(manager, "_cleanup_files"),
|
||||
patch("frigate.debug_replay.cancel_debug_replay_job", return_value=False),
|
||||
):
|
||||
manager.stop(frigate_config=frigate_config, config_publisher=publisher)
|
||||
|
||||
# One publish_update call with a remove topic.
|
||||
self.assertEqual(publisher.publish_update.call_count, 1)
|
||||
topic_arg = publisher.publish_update.call_args.args[0]
|
||||
self.assertEqual(topic_arg.update_type, CameraConfigUpdateEnum.remove)
|
||||
self.assertFalse(manager.active)
|
||||
|
||||
def test_stop_skips_remove_publish_when_camera_not_in_config(self) -> None:
|
||||
"""Cancellation during preparing_clip: no camera was published yet."""
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
manager = DebugReplayManager()
|
||||
manager.mark_starting("front", "_replay_front", 100.0, 200.0)
|
||||
# clip_path stays None because we cancelled before camera publish.
|
||||
|
||||
frigate_config = MagicMock()
|
||||
frigate_config.cameras = {} # _replay_front not present
|
||||
publisher = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(manager, "_cleanup_db"),
|
||||
patch.object(manager, "_cleanup_files"),
|
||||
patch("frigate.debug_replay.cancel_debug_replay_job", return_value=True),
|
||||
):
|
||||
manager.stop(frigate_config=frigate_config, config_publisher=publisher)
|
||||
|
||||
publisher.publish_update.assert_not_called()
|
||||
self.assertFalse(manager.active)
|
||||
|
||||
def test_stop_calls_cancel_debug_replay_job(self) -> None:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
manager = DebugReplayManager()
|
||||
manager.mark_starting("front", "_replay_front", 100.0, 200.0)
|
||||
|
||||
frigate_config = MagicMock()
|
||||
frigate_config.cameras = {}
|
||||
publisher = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(manager, "_cleanup_db"),
|
||||
patch.object(manager, "_cleanup_files"),
|
||||
patch(
|
||||
"frigate.debug_replay.cancel_debug_replay_job",
|
||||
return_value=True,
|
||||
) as mock_cancel,
|
||||
):
|
||||
manager.stop(frigate_config=frigate_config, config_publisher=publisher)
|
||||
|
||||
mock_cancel.assert_called_once()
|
||||
|
||||
|
||||
class TestDebugReplayManagerPublishCamera(unittest.TestCase):
|
||||
def test_publish_camera_invokes_publisher_with_add_topic(self) -> None:
|
||||
from frigate.config.camera.updater import CameraConfigUpdateEnum
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
manager = DebugReplayManager()
|
||||
|
||||
source_config = MagicMock()
|
||||
new_camera_config = MagicMock()
|
||||
frigate_config = MagicMock()
|
||||
frigate_config.cameras = {"front": source_config}
|
||||
publisher = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
manager,
|
||||
"_build_camera_config_dict",
|
||||
return_value={"enabled": True},
|
||||
),
|
||||
patch("frigate.debug_replay.find_config_file", return_value="/cfg.yml"),
|
||||
patch("frigate.debug_replay.YAML") as yaml_cls,
|
||||
patch("frigate.debug_replay.FrigateConfig.parse_object") as parse_object,
|
||||
patch("builtins.open", unittest.mock.mock_open(read_data="cameras:\n")),
|
||||
):
|
||||
yaml_instance = yaml_cls.return_value
|
||||
yaml_instance.load.return_value = {"cameras": {}}
|
||||
parsed = MagicMock()
|
||||
parsed.cameras = {"_replay_front": new_camera_config}
|
||||
parse_object.return_value = parsed
|
||||
|
||||
manager.publish_camera(
|
||||
source_camera="front",
|
||||
replay_name="_replay_front",
|
||||
clip_path="/tmp/clip.mp4",
|
||||
frigate_config=frigate_config,
|
||||
config_publisher=publisher,
|
||||
)
|
||||
|
||||
# Camera registered into the live config dict
|
||||
self.assertIn("_replay_front", frigate_config.cameras)
|
||||
# Publisher invoked with an add topic
|
||||
self.assertEqual(publisher.publish_update.call_count, 1)
|
||||
topic_arg = publisher.publish_update.call_args.args[0]
|
||||
self.assertEqual(topic_arg.update_type, CameraConfigUpdateEnum.add)
|
||||
|
||||
def test_publish_camera_wraps_parse_failure_in_runtime_error(self) -> None:
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
|
||||
manager = DebugReplayManager()
|
||||
frigate_config = MagicMock()
|
||||
frigate_config.cameras = {"front": MagicMock()}
|
||||
publisher = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
manager,
|
||||
"_build_camera_config_dict",
|
||||
return_value={"enabled": True},
|
||||
),
|
||||
patch("frigate.debug_replay.find_config_file", return_value="/cfg.yml"),
|
||||
patch("frigate.debug_replay.YAML") as yaml_cls,
|
||||
patch(
|
||||
"frigate.debug_replay.FrigateConfig.parse_object",
|
||||
side_effect=ValueError("zone foo has invalid coordinates"),
|
||||
),
|
||||
patch("builtins.open", unittest.mock.mock_open(read_data="cameras:\n")),
|
||||
):
|
||||
yaml_cls.return_value.load.return_value = {"cameras": {}}
|
||||
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
manager.publish_camera(
|
||||
source_camera="front",
|
||||
replay_name="_replay_front",
|
||||
clip_path="/tmp/clip.mp4",
|
||||
frigate_config=frigate_config,
|
||||
config_publisher=publisher,
|
||||
)
|
||||
|
||||
self.assertIn("replay camera config", str(ctx.exception))
|
||||
self.assertIn("invalid coordinates", str(ctx.exception))
|
||||
publisher.publish_update.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,460 +0,0 @@
|
||||
"""Tests for the debug replay job runner and factory."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.debug_replay import DebugReplayManager
|
||||
from frigate.jobs.debug_replay import (
|
||||
DebugReplayJob,
|
||||
cancel_debug_replay_job,
|
||||
get_active_runner,
|
||||
start_debug_replay_job,
|
||||
)
|
||||
from frigate.jobs.export import JobStatePublisher
|
||||
from frigate.jobs.manager import _completed_jobs, _current_jobs
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
|
||||
|
||||
def _reset_job_manager() -> None:
|
||||
"""Clear the global job manager state between tests."""
|
||||
_current_jobs.clear()
|
||||
_completed_jobs.clear()
|
||||
|
||||
|
||||
def _patch_publisher(test_case: unittest.TestCase) -> None:
|
||||
"""Replace JobStatePublisher.publish with a no-op to avoid hanging on IPC."""
|
||||
publisher_patch = patch.object(
|
||||
JobStatePublisher, "publish", lambda self, payload: None
|
||||
)
|
||||
publisher_patch.start()
|
||||
test_case.addCleanup(publisher_patch.stop)
|
||||
|
||||
|
||||
class TestDebugReplayJob(unittest.TestCase):
|
||||
def test_default_fields(self) -> None:
|
||||
job = DebugReplayJob()
|
||||
|
||||
self.assertEqual(job.job_type, "debug_replay")
|
||||
self.assertEqual(job.status, JobStatusTypesEnum.queued)
|
||||
self.assertIsNone(job.current_step)
|
||||
self.assertEqual(job.progress_percent, 0.0)
|
||||
|
||||
def test_to_dict_whitelist(self) -> None:
|
||||
job = DebugReplayJob(
|
||||
source_camera="front",
|
||||
replay_camera_name="_replay_front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
)
|
||||
job.current_step = "preparing_clip"
|
||||
job.progress_percent = 42.5
|
||||
|
||||
payload = job.to_dict()
|
||||
|
||||
# Top-level matches the standard Job<TResults> shape.
|
||||
for key in (
|
||||
"id",
|
||||
"job_type",
|
||||
"status",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"error_message",
|
||||
"results",
|
||||
):
|
||||
self.assertIn(key, payload, f"missing top-level field: {key}")
|
||||
|
||||
results = payload["results"]
|
||||
self.assertEqual(results["source_camera"], "front")
|
||||
self.assertEqual(results["replay_camera_name"], "_replay_front")
|
||||
self.assertEqual(results["current_step"], "preparing_clip")
|
||||
self.assertEqual(results["progress_percent"], 42.5)
|
||||
self.assertEqual(results["start_ts"], 100.0)
|
||||
self.assertEqual(results["end_ts"], 200.0)
|
||||
|
||||
|
||||
class TestStartDebugReplayJob(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_job_manager()
|
||||
_patch_publisher(self)
|
||||
self.manager = DebugReplayManager()
|
||||
self.frigate_config = MagicMock()
|
||||
self.frigate_config.cameras = {"front": MagicMock()}
|
||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||
self.publisher = MagicMock()
|
||||
|
||||
self.recordings_qs = MagicMock()
|
||||
self.recordings_qs.count.return_value = 1
|
||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
runner = get_active_runner()
|
||||
if runner is not None:
|
||||
runner.cancel()
|
||||
runner.join(timeout=2.0)
|
||||
_reset_job_manager()
|
||||
|
||||
def test_rejects_unknown_camera(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source_camera="missing",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
)
|
||||
|
||||
def test_rejects_invalid_time_range(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=200.0,
|
||||
end_ts=100.0,
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
)
|
||||
|
||||
def test_rejects_when_no_recordings(self) -> None:
|
||||
empty_qs = MagicMock()
|
||||
empty_qs.count.return_value = 0
|
||||
with patch("frigate.jobs.debug_replay.query_recordings", return_value=empty_qs):
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
)
|
||||
|
||||
def test_returns_job_id_and_marks_session_starting(self) -> None:
|
||||
block = threading.Event()
|
||||
|
||||
def slow_helper(cmd, **kwargs):
|
||||
block.wait(timeout=5)
|
||||
return 0, ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.query_recordings",
|
||||
return_value=self.recordings_qs,
|
||||
),
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||
side_effect=slow_helper,
|
||||
),
|
||||
patch.object(self.manager, "publish_camera"),
|
||||
patch("os.path.exists", return_value=True),
|
||||
patch("os.makedirs"),
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
job_id = start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
)
|
||||
|
||||
self.assertIsInstance(job_id, str)
|
||||
self.assertTrue(self.manager.active)
|
||||
self.assertEqual(self.manager.replay_camera_name, "_replay_front")
|
||||
self.assertEqual(self.manager.source_camera, "front")
|
||||
|
||||
block.set()
|
||||
|
||||
def test_rejects_concurrent_calls(self) -> None:
|
||||
block = threading.Event()
|
||||
|
||||
def slow_helper(cmd, **kwargs):
|
||||
block.wait(timeout=5)
|
||||
return 0, ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.query_recordings",
|
||||
return_value=self.recordings_qs,
|
||||
),
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||
side_effect=slow_helper,
|
||||
),
|
||||
patch.object(self.manager, "publish_camera"),
|
||||
patch("os.path.exists", return_value=True),
|
||||
patch("os.makedirs"),
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
)
|
||||
|
||||
with self.assertRaises(RuntimeError):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
)
|
||||
|
||||
block.set()
|
||||
|
||||
|
||||
class TestRunnerHappyPath(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_job_manager()
|
||||
_patch_publisher(self)
|
||||
self.manager = DebugReplayManager()
|
||||
self.frigate_config = MagicMock()
|
||||
self.frigate_config.cameras = {"front": MagicMock()}
|
||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||
self.publisher = MagicMock()
|
||||
|
||||
self.recordings_qs = MagicMock()
|
||||
self.recordings_qs.count.return_value = 1
|
||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
runner = get_active_runner()
|
||||
if runner is not None:
|
||||
runner.cancel()
|
||||
runner.join(timeout=2.0)
|
||||
_reset_job_manager()
|
||||
|
||||
def _wait_for(self, predicate, timeout: float = 5.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return False
|
||||
|
||||
def test_progress_callback_updates_job_percent(self) -> None:
|
||||
captured: list[float] = []
|
||||
|
||||
def fake_helper(cmd, *, on_progress=None, **kwargs):
|
||||
on_progress(0.0)
|
||||
on_progress(50.0)
|
||||
on_progress(100.0)
|
||||
return 0, ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.query_recordings",
|
||||
return_value=self.recordings_qs,
|
||||
),
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||
side_effect=fake_helper,
|
||||
),
|
||||
patch.object(
|
||||
self.manager,
|
||||
"publish_camera",
|
||||
side_effect=lambda *a, **kw: captured.append("published"),
|
||||
),
|
||||
patch("os.path.exists", return_value=True),
|
||||
patch("os.makedirs"),
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
self._wait_for(lambda: get_active_runner() is None),
|
||||
"runner did not finish",
|
||||
)
|
||||
|
||||
from frigate.jobs.manager import get_current_job
|
||||
|
||||
job = get_current_job("debug_replay")
|
||||
self.assertIsNotNone(job)
|
||||
self.assertEqual(job.status, JobStatusTypesEnum.success)
|
||||
self.assertEqual(job.progress_percent, 100.0)
|
||||
self.assertEqual(captured, ["published"])
|
||||
# Manager should have been told the session is ready with the clip path.
|
||||
self.assertIsNotNone(self.manager.clip_path)
|
||||
|
||||
|
||||
class TestRunnerFailurePath(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_job_manager()
|
||||
_patch_publisher(self)
|
||||
self.manager = DebugReplayManager()
|
||||
self.frigate_config = MagicMock()
|
||||
self.frigate_config.cameras = {"front": MagicMock()}
|
||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||
self.publisher = MagicMock()
|
||||
self.recordings_qs = MagicMock()
|
||||
self.recordings_qs.count.return_value = 1
|
||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
runner = get_active_runner()
|
||||
if runner is not None:
|
||||
runner.cancel()
|
||||
runner.join(timeout=2.0)
|
||||
_reset_job_manager()
|
||||
|
||||
def _wait_for(self, predicate, timeout: float = 5.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return False
|
||||
|
||||
def test_ffmpeg_failure_marks_job_failed_and_clears_session(self) -> None:
|
||||
def failing_helper(cmd, **kwargs):
|
||||
return 1, "ffmpeg exploded"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.query_recordings",
|
||||
return_value=self.recordings_qs,
|
||||
),
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||
side_effect=failing_helper,
|
||||
),
|
||||
patch("os.path.exists", return_value=True),
|
||||
patch("os.makedirs"),
|
||||
patch("os.remove"),
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
self._wait_for(lambda: get_active_runner() is None),
|
||||
"runner did not finish",
|
||||
)
|
||||
|
||||
from frigate.jobs.manager import get_current_job
|
||||
|
||||
job = get_current_job("debug_replay")
|
||||
self.assertIsNotNone(job)
|
||||
self.assertEqual(job.status, JobStatusTypesEnum.failed)
|
||||
self.assertIsNotNone(job.error_message)
|
||||
self.assertIn("ffmpeg", job.error_message.lower())
|
||||
# Session cleared so a new /start is allowed
|
||||
self.assertFalse(self.manager.active)
|
||||
|
||||
|
||||
class TestRunnerCancellation(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_job_manager()
|
||||
_patch_publisher(self)
|
||||
self.manager = DebugReplayManager()
|
||||
self.frigate_config = MagicMock()
|
||||
self.frigate_config.cameras = {"front": MagicMock()}
|
||||
self.frigate_config.ffmpeg.ffmpeg_path = "/bin/true"
|
||||
self.publisher = MagicMock()
|
||||
self.recordings_qs = MagicMock()
|
||||
self.recordings_qs.count.return_value = 1
|
||||
self.recordings_qs.__iter__.return_value = iter([MagicMock(path="/tmp/r1.mp4")])
|
||||
|
||||
def tearDown(self) -> None:
|
||||
runner = get_active_runner()
|
||||
if runner is not None:
|
||||
runner.cancel()
|
||||
runner.join(timeout=2.0)
|
||||
_reset_job_manager()
|
||||
|
||||
def _wait_for(self, predicate, timeout: float = 5.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return False
|
||||
|
||||
def test_cancel_terminates_ffmpeg_and_marks_cancelled(self) -> None:
|
||||
terminated = threading.Event()
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.terminate = MagicMock(side_effect=lambda: terminated.set())
|
||||
|
||||
def fake_helper(cmd, *, process_started=None, **kwargs):
|
||||
if process_started is not None:
|
||||
process_started(fake_proc)
|
||||
terminated.wait(timeout=5)
|
||||
return -15, "killed"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.query_recordings",
|
||||
return_value=self.recordings_qs,
|
||||
),
|
||||
patch(
|
||||
"frigate.jobs.debug_replay.run_ffmpeg_with_progress",
|
||||
side_effect=fake_helper,
|
||||
),
|
||||
patch("os.path.exists", return_value=True),
|
||||
patch("os.makedirs"),
|
||||
patch("os.remove"),
|
||||
patch("builtins.open", unittest.mock.mock_open()),
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
replay_manager=self.manager,
|
||||
)
|
||||
|
||||
# Wait for the runner to register the active process.
|
||||
self.assertTrue(
|
||||
self._wait_for(
|
||||
lambda: (
|
||||
get_active_runner() is not None
|
||||
and get_active_runner()._active_process is fake_proc
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
cancelled = cancel_debug_replay_job()
|
||||
self.assertTrue(cancelled)
|
||||
self.assertTrue(fake_proc.terminate.called)
|
||||
|
||||
self.assertTrue(
|
||||
self._wait_for(lambda: get_active_runner() is None),
|
||||
"runner did not finish",
|
||||
)
|
||||
|
||||
from frigate.jobs.manager import get_current_job
|
||||
|
||||
job = get_current_job("debug_replay")
|
||||
self.assertEqual(job.status, JobStatusTypesEnum.cancelled)
|
||||
# Runner must not clear the manager session on cancellation —
|
||||
# that belongs to the caller of cancel_debug_replay_job (stop()).
|
||||
# If the runner cleared it, stop() would log "no active session"
|
||||
# and skip its cleanup_db / cleanup_files calls.
|
||||
self.assertTrue(self.manager.active)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,9 +1,6 @@
|
||||
"""Tests for export progress tracking, broadcast, and FFmpeg parsing."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -14,7 +11,6 @@ from frigate.jobs.export import (
|
||||
)
|
||||
from frigate.record.export import PlaybackSourceEnum, RecordingExporter
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.ffmpeg import inject_progress_flags
|
||||
|
||||
|
||||
def _make_exporter(
|
||||
@@ -119,9 +115,10 @@ class TestExpectedOutputDuration(unittest.TestCase):
|
||||
|
||||
class TestProgressFlagInjection(unittest.TestCase):
|
||||
def test_inserts_before_output_path(self) -> None:
|
||||
exporter = _make_exporter()
|
||||
cmd = ["ffmpeg", "-i", "input.m3u8", "-c", "copy", "/tmp/output.mp4"]
|
||||
|
||||
result = inject_progress_flags(cmd)
|
||||
result = exporter._inject_progress_flags(cmd)
|
||||
|
||||
assert result == [
|
||||
"ffmpeg",
|
||||
@@ -136,7 +133,8 @@ class TestProgressFlagInjection(unittest.TestCase):
|
||||
]
|
||||
|
||||
def test_handles_empty_cmd(self) -> None:
|
||||
assert inject_progress_flags([]) == []
|
||||
exporter = _make_exporter()
|
||||
assert exporter._inject_progress_flags([]) == []
|
||||
|
||||
|
||||
class TestFfmpegProgressParsing(unittest.TestCase):
|
||||
@@ -166,7 +164,7 @@ class TestFfmpegProgressParsing(unittest.TestCase):
|
||||
fake_proc.returncode = 0
|
||||
fake_proc.wait = MagicMock(return_value=0)
|
||||
|
||||
with patch("frigate.util.ffmpeg.sp.Popen", return_value=fake_proc):
|
||||
with patch("frigate.record.export.sp.Popen", return_value=fake_proc):
|
||||
returncode, _stderr = exporter._run_ffmpeg_with_progress(
|
||||
["ffmpeg", "-i", "x.m3u8", "/tmp/out.mp4"], "playlist", step="encoding"
|
||||
)
|
||||
@@ -365,121 +363,6 @@ class TestBroadcastAggregation(unittest.TestCase):
|
||||
assert job.progress_percent == 33.0
|
||||
|
||||
|
||||
class TestGetDatetimeFromTimestamp(unittest.TestCase):
|
||||
"""Auto-generated export name should honor config.ui.timezone, not
|
||||
fall back to the container's UTC clock when a timezone is configured.
|
||||
"""
|
||||
|
||||
def test_uses_configured_ui_timezone(self) -> None:
|
||||
exporter = _make_exporter()
|
||||
exporter.config.ui.timezone = "America/New_York"
|
||||
# 2025-01-15 12:00:00 UTC is 07:00:00 EST
|
||||
assert exporter.get_datetime_from_timestamp(1736942400) == "2025-01-15 07:00:00"
|
||||
|
||||
def test_falls_back_to_local_when_timezone_unset(self) -> None:
|
||||
exporter = _make_exporter()
|
||||
exporter.config.ui.timezone = None
|
||||
# No assertion on the exact wall-clock value — just confirm no
|
||||
# exception and that pytz isn't required when the field is unset.
|
||||
assert isinstance(exporter.get_datetime_from_timestamp(1736942400), str)
|
||||
|
||||
def test_invalid_timezone_falls_back_to_local(self) -> None:
|
||||
exporter = _make_exporter()
|
||||
exporter.config.ui.timezone = "Not/A_Real_Zone"
|
||||
assert isinstance(exporter.get_datetime_from_timestamp(1736942400), str)
|
||||
|
||||
|
||||
class TestSaveThumbnailFromPreviewFrames(unittest.TestCase):
|
||||
"""Short exports in the current hour can fall between preview frame
|
||||
writes (1-2 fps during activity, every 30s otherwise). When no frame
|
||||
falls inside the export window, save_thumbnail should fall back to
|
||||
the most recent prior frame instead of returning no thumbnail."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp_root = tempfile.mkdtemp(prefix="frigate_thumb_test_")
|
||||
self.preview_dir = os.path.join(self.tmp_root, "cache", "preview_frames")
|
||||
self.export_clips = os.path.join(self.tmp_root, "clips", "export")
|
||||
os.makedirs(self.preview_dir, exist_ok=True)
|
||||
os.makedirs(self.export_clips, exist_ok=True)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.tmp_root, ignore_errors=True)
|
||||
|
||||
def _write_frame(self, camera: str, frame_time: float) -> str:
|
||||
path = os.path.join(self.preview_dir, f"preview_{camera}-{frame_time}.webp")
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"fake-webp-bytes")
|
||||
return path
|
||||
|
||||
def _make_short_current_hour_exporter(self) -> RecordingExporter:
|
||||
# Use a "now-ish" timestamp so save_thumbnail's start-of-hour
|
||||
# comparison takes the current-hour branch (preview frames).
|
||||
import datetime
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
|
||||
exporter = _make_exporter()
|
||||
exporter.export_id = "thumb_short"
|
||||
exporter.start_time = now
|
||||
exporter.end_time = now + 3
|
||||
return exporter
|
||||
|
||||
def test_short_export_falls_back_to_prior_preview_frame(self) -> None:
|
||||
exporter = self._make_short_current_hour_exporter()
|
||||
# Most recent preview frame is 10s before the export window
|
||||
prior = self._write_frame(exporter.camera, exporter.start_time - 10.0)
|
||||
thumb_target = os.path.join(self.export_clips, f"{exporter.export_id}.webp")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache")
|
||||
),
|
||||
patch(
|
||||
"frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips")
|
||||
),
|
||||
):
|
||||
result = exporter.save_thumbnail(exporter.export_id)
|
||||
|
||||
assert result == thumb_target
|
||||
assert os.path.isfile(thumb_target)
|
||||
with open(thumb_target, "rb") as f, open(prior, "rb") as src:
|
||||
assert f.read() == src.read()
|
||||
|
||||
def test_returns_empty_when_no_preview_frames_exist(self) -> None:
|
||||
exporter = self._make_short_current_hour_exporter()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache")
|
||||
),
|
||||
patch(
|
||||
"frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips")
|
||||
),
|
||||
):
|
||||
result = exporter.save_thumbnail(exporter.export_id)
|
||||
|
||||
assert result == ""
|
||||
|
||||
def test_prefers_in_window_frame_over_prior_frame(self) -> None:
|
||||
exporter = self._make_short_current_hour_exporter()
|
||||
self._write_frame(exporter.camera, exporter.start_time - 10.0)
|
||||
in_window = self._write_frame(exporter.camera, exporter.start_time + 1.0)
|
||||
thumb_target = os.path.join(self.export_clips, f"{exporter.export_id}.webp")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"frigate.record.export.CACHE_DIR", os.path.join(self.tmp_root, "cache")
|
||||
),
|
||||
patch(
|
||||
"frigate.record.export.CLIPS_DIR", os.path.join(self.tmp_root, "clips")
|
||||
),
|
||||
):
|
||||
result = exporter.save_thumbnail(exporter.export_id)
|
||||
|
||||
assert result == thumb_target
|
||||
with open(thumb_target, "rb") as f, open(in_window, "rb") as src:
|
||||
assert f.read() == src.read()
|
||||
|
||||
|
||||
class TestSchedulesCleanup(unittest.TestCase):
|
||||
def test_schedule_job_cleanup_removes_after_delay(self) -> None:
|
||||
config = MagicMock()
|
||||
@@ -498,56 +381,5 @@ class TestSchedulesCleanup(unittest.TestCase):
|
||||
assert job.id not in manager.jobs
|
||||
|
||||
|
||||
class TestChapterMetadataInProgressReview(unittest.TestCase):
|
||||
"""Regression: in-progress review segments have end_time=NULL until the
|
||||
activity closes. The chapter builder must clamp the chapter end to the
|
||||
last recorded second instead of crashing on float(None)."""
|
||||
|
||||
def _fake_select_returning(self, rows: list) -> MagicMock:
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.iterator.return_value = iter(rows)
|
||||
return mock_query
|
||||
|
||||
def test_in_progress_review_does_not_crash_and_clamps_to_last_recording(
|
||||
self,
|
||||
) -> None:
|
||||
exporter = _make_exporter(end_minus_start=200)
|
||||
# Recordings cover [1000, 1150]; export window is [1000, 1200] so
|
||||
# the last recorded second is 1150 (a 50s gap at the tail).
|
||||
recordings = [
|
||||
MagicMock(start_time=1000.0, end_time=1150.0),
|
||||
]
|
||||
in_progress = MagicMock(
|
||||
start_time=1100.0,
|
||||
end_time=None,
|
||||
severity="alert",
|
||||
data={"objects": ["person"]},
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
chapter_path = os.path.join(tmpdir, "chapters.txt")
|
||||
exporter._chapter_metadata_path = lambda: chapter_path # type: ignore[method-assign]
|
||||
|
||||
with patch(
|
||||
"frigate.record.export.ReviewSegment.select",
|
||||
return_value=self._fake_select_returning([in_progress]),
|
||||
):
|
||||
result = exporter._build_chapter_metadata_file(recordings)
|
||||
|
||||
assert result == chapter_path
|
||||
with open(chapter_path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Output time is windows[-1][1] - windows[-1][0] = 150s.
|
||||
# Review starts at wall=1100, output offset = 100s -> 100000ms.
|
||||
# Clamped end = last_recorded_end (1150) -> output offset = 150s -> 150000ms.
|
||||
assert "[CHAPTER]" in content
|
||||
assert "START=100000" in content
|
||||
assert "END=150000" in content
|
||||
assert "title=Alert: person" in content
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Tests for the shared ffmpeg progress helper."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.util.ffmpeg import inject_progress_flags, run_ffmpeg_with_progress
|
||||
|
||||
|
||||
class TestInjectProgressFlags(unittest.TestCase):
|
||||
def test_inserts_flags_before_output_path(self):
|
||||
cmd = ["ffmpeg", "-i", "in.mp4", "-c", "copy", "out.mp4"]
|
||||
result = inject_progress_flags(cmd)
|
||||
self.assertEqual(
|
||||
result,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
"in.mp4",
|
||||
"-c",
|
||||
"copy",
|
||||
"-progress",
|
||||
"pipe:2",
|
||||
"-nostats",
|
||||
"out.mp4",
|
||||
],
|
||||
)
|
||||
|
||||
def test_empty_cmd_returns_empty(self):
|
||||
self.assertEqual(inject_progress_flags([]), [])
|
||||
|
||||
|
||||
class TestRunFfmpegWithProgress(unittest.TestCase):
|
||||
def _make_fake_proc(self, stderr_lines, returncode=0):
|
||||
proc = MagicMock()
|
||||
proc.stderr = iter(stderr_lines)
|
||||
proc.stdin = MagicMock()
|
||||
proc.returncode = returncode
|
||||
proc.wait = MagicMock()
|
||||
return proc
|
||||
|
||||
def test_emits_percent_from_out_time_us_lines(self):
|
||||
captured: list[float] = []
|
||||
|
||||
def on_progress(percent: float) -> None:
|
||||
captured.append(percent)
|
||||
|
||||
stderr_lines = [
|
||||
"out_time_us=1000000\n",
|
||||
"out_time_us=5000000\n",
|
||||
"progress=end\n",
|
||||
]
|
||||
proc = self._make_fake_proc(stderr_lines)
|
||||
proc.stderr = MagicMock()
|
||||
proc.stderr.__iter__ = lambda self: iter(stderr_lines)
|
||||
proc.stderr.read = MagicMock(return_value="")
|
||||
|
||||
with patch("subprocess.Popen", return_value=proc):
|
||||
returncode, _stderr = run_ffmpeg_with_progress(
|
||||
["ffmpeg", "-i", "in", "out"],
|
||||
expected_duration_seconds=10.0,
|
||||
on_progress=on_progress,
|
||||
use_low_priority=False,
|
||||
)
|
||||
|
||||
self.assertEqual(returncode, 0)
|
||||
self.assertEqual(len(captured), 4) # initial 0.0 + two parsed + final 100.0
|
||||
self.assertAlmostEqual(captured[0], 0.0)
|
||||
self.assertAlmostEqual(captured[1], 10.0)
|
||||
self.assertAlmostEqual(captured[2], 50.0)
|
||||
self.assertAlmostEqual(captured[3], 100.0)
|
||||
|
||||
def test_passes_started_process_to_callback(self):
|
||||
proc = self._make_fake_proc([])
|
||||
proc.stderr = MagicMock()
|
||||
proc.stderr.__iter__ = lambda self: iter([])
|
||||
proc.stderr.read = MagicMock(return_value="")
|
||||
|
||||
seen: list = []
|
||||
|
||||
with patch("subprocess.Popen", return_value=proc):
|
||||
run_ffmpeg_with_progress(
|
||||
["ffmpeg", "out"],
|
||||
expected_duration_seconds=1.0,
|
||||
process_started=lambda p: seen.append(p),
|
||||
use_low_priority=False,
|
||||
)
|
||||
|
||||
self.assertEqual(seen, [proc])
|
||||
|
||||
def test_clamps_percent_to_0_100(self):
|
||||
captured: list[float] = []
|
||||
|
||||
def on_progress(percent: float) -> None:
|
||||
captured.append(percent)
|
||||
|
||||
stderr_lines = ["out_time_us=999999999999\n"]
|
||||
proc = self._make_fake_proc(stderr_lines)
|
||||
proc.stderr = MagicMock()
|
||||
proc.stderr.__iter__ = lambda self: iter(stderr_lines)
|
||||
proc.stderr.read = MagicMock(return_value="")
|
||||
|
||||
with patch("subprocess.Popen", return_value=proc):
|
||||
run_ffmpeg_with_progress(
|
||||
["ffmpeg", "out"],
|
||||
expected_duration_seconds=10.0,
|
||||
on_progress=on_progress,
|
||||
use_low_priority=False,
|
||||
)
|
||||
|
||||
# initial 0.0 then a clamped reading
|
||||
self.assertEqual(captured[-1], 100.0)
|
||||
@@ -7,6 +7,8 @@ from frigate.util.services import get_amd_gpu_stats, get_intel_gpu_stats
|
||||
class TestGpuStats(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.amd_results = "Unknown Radeon card. <= R500 won't work, new cards might.\nDumping to -, line limit 1.\n1664070990.607556: bus 10, gpu 4.17%, ee 0.00%, vgt 0.00%, ta 0.00%, tc 0.00%, sx 0.00%, sh 0.00%, spi 0.83%, smx 0.00%, cr 0.00%, sc 0.00%, pa 0.00%, db 0.00%, cb 0.00%, vram 60.37% 294.04mb, gtt 0.33% 52.21mb, mclk 100.00% 1.800ghz, sclk 26.65% 0.533ghz\n"
|
||||
self.intel_results = """{"period":{"duration":1.194033,"unit":"ms"},"frequency":{"requested":0.000000,"actual":0.000000,"unit":"MHz"},"interrupts":{"count":3349.991164,"unit":"irq/s"},"rc6":{"value":47.844741,"unit":"%"},"engines":{"Render/3D/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Blitter/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/0":{"busy":4.533124,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/1":{"busy":6.194385,"sema":0.000000,"wait":0.000000,"unit":"%"},"VideoEnhance/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"}}},{"period":{"duration":1.189291,"unit":"ms"},"frequency":{"requested":0.000000,"actual":0.000000,"unit":"MHz"},"interrupts":{"count":0.000000,"unit":"irq/s"},"rc6":{"value":100.000000,"unit":"%"},"engines":{"Render/3D/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Blitter/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"Video/1":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"},"VideoEnhance/0":{"busy":0.000000,"sema":0.000000,"wait":0.000000,"unit":"%"}}}"""
|
||||
self.nvidia_results = "name, utilization.gpu [%], memory.used [MiB], memory.total [MiB]\nNVIDIA GeForce RTX 3050, 42 %, 5036 MiB, 8192 MiB\n"
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_amd_gpu_stats(self, sp):
|
||||
@@ -17,76 +19,32 @@ 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.monotonic")
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
def test_intel_gpu_stats_fdinfo(self, read_fdinfo, monotonic, sleep):
|
||||
# 1 second of wall clock between snapshots
|
||||
monotonic.side_effect = [0.0, 1.0]
|
||||
# @patch("subprocess.run")
|
||||
# def test_nvidia_gpu_stats(self, sp):
|
||||
# process = MagicMock()
|
||||
# process.returncode = 0
|
||||
# process.stdout = self.nvidia_results
|
||||
# sp.return_value = process
|
||||
# nvidia_stats = get_nvidia_gpu_stats()
|
||||
# assert nvidia_stats == {
|
||||
# "name": "NVIDIA GeForce RTX 3050",
|
||||
# "gpu": "42 %",
|
||||
# "mem": "61.5 %",
|
||||
# }
|
||||
|
||||
# Two i915 clients on the same iGPU. Engine values are cumulative ns.
|
||||
# Deltas over the 1s window:
|
||||
# client A (pid 100): render +200_000_000 (20%), video +500_000_000 (50%),
|
||||
# video-enhance +100_000_000 (10%)
|
||||
# client B (pid 200): compute +100_000_000 (10%)
|
||||
# Engine totals → render 20, video 50, video-enhance 10, compute 10
|
||||
# → compute = render + compute = 30
|
||||
# → dec = video + video-enhance = 60
|
||||
# → gpu = compute + dec = 90
|
||||
snapshot_a = {
|
||||
("0000:00:02.0", "1", "100"): {
|
||||
"driver": "i915",
|
||||
"pid": "100",
|
||||
"engines": {
|
||||
"render": (1_000_000_000, 0),
|
||||
"video": (5_000_000_000, 0),
|
||||
"video-enhance": (200_000_000, 0),
|
||||
"compute": (0, 0),
|
||||
},
|
||||
},
|
||||
("0000:00:02.0", "2", "200"): {
|
||||
"driver": "i915",
|
||||
"pid": "200",
|
||||
"engines": {
|
||||
"render": (0, 0),
|
||||
"compute": (2_000_000_000, 0),
|
||||
},
|
||||
},
|
||||
}
|
||||
snapshot_b = {
|
||||
("0000:00:02.0", "1", "100"): {
|
||||
"driver": "i915",
|
||||
"pid": "100",
|
||||
"engines": {
|
||||
"render": (1_200_000_000, 0),
|
||||
"video": (5_500_000_000, 0),
|
||||
"video-enhance": (300_000_000, 0),
|
||||
"compute": (0, 0),
|
||||
},
|
||||
},
|
||||
("0000:00:02.0", "2", "200"): {
|
||||
"driver": "i915",
|
||||
"pid": "200",
|
||||
"engines": {
|
||||
"render": (0, 0),
|
||||
"compute": (2_100_000_000, 0),
|
||||
},
|
||||
},
|
||||
}
|
||||
read_fdinfo.side_effect = [snapshot_a, snapshot_b]
|
||||
|
||||
intel_stats = get_intel_gpu_stats(None)
|
||||
|
||||
sleep.assert_called_once()
|
||||
@patch("subprocess.run")
|
||||
def test_intel_gpu_stats(self, sp):
|
||||
process = MagicMock()
|
||||
process.returncode = 124
|
||||
process.stdout = self.intel_results
|
||||
sp.return_value = process
|
||||
intel_stats = get_intel_gpu_stats(False)
|
||||
# rc6 values: 47.844741 and 100.0 → avg 73.92 → gpu = 100 - 73.92 = 26.08%
|
||||
# Render/3D/0: 0.0 and 0.0 → enc = 0.0%
|
||||
# Video/0: 4.533124 and 0.0 → dec = 2.27%
|
||||
assert intel_stats == {
|
||||
"gpu": "90.0%",
|
||||
"gpu": "26.08%",
|
||||
"mem": "-%",
|
||||
"compute": "30.0%",
|
||||
"dec": "60.0%",
|
||||
"clients": {"100": "80.0%", "200": "10.0%"},
|
||||
"compute": "0.0%",
|
||||
"dec": "2.27%",
|
||||
}
|
||||
|
||||
@patch("frigate.util.services._read_intel_drm_fdinfo")
|
||||
def test_intel_gpu_stats_no_clients(self, read_fdinfo):
|
||||
read_fdinfo.return_value = {}
|
||||
assert get_intel_gpu_stats(None) is None
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""Tests for WebSocket authorization checks."""
|
||||
|
||||
import unittest
|
||||
|
||||
from frigate.comms.ws import _check_ws_authorization
|
||||
from frigate.const import INSERT_MANY_RECORDINGS, UPDATE_CAMERA_ACTIVITY
|
||||
|
||||
|
||||
class TestCheckWsAuthorization(unittest.TestCase):
|
||||
"""Tests for the _check_ws_authorization pure function."""
|
||||
|
||||
DEFAULT_SEPARATOR = ","
|
||||
|
||||
# --- IPC topic blocking (unconditional, regardless of role) ---
|
||||
|
||||
def test_ipc_topic_blocked_for_admin(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
INSERT_MANY_RECORDINGS, "admin", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_ipc_topic_blocked_for_viewer(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
UPDATE_CAMERA_ACTIVITY, "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_ipc_topic_blocked_when_no_role(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
INSERT_MANY_RECORDINGS, None, self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Viewer allowed topics ---
|
||||
|
||||
def test_viewer_can_send_on_connect(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("onConnect", "viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_viewer_can_send_model_state(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("modelState", "viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_viewer_can_send_audio_transcription_state(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"audioTranscriptionState", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_can_send_birdseye_layout(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("birdseyeLayout", "viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_viewer_can_send_embeddings_reindex_progress(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"embeddingsReindexProgress", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Viewer blocked from admin topics ---
|
||||
|
||||
def test_viewer_blocked_from_restart(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization("restart", "viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_camera_detect_set(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"front_door/detect/set", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_camera_ptz(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization("front_door/ptz", "viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_global_notifications_set(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"notifications/set", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_camera_notifications_suspend(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"front_door/notifications/suspend", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_viewer_blocked_from_arbitrary_unknown_topic(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization(
|
||||
"some_random_topic", "viewer", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
# --- Admin access ---
|
||||
|
||||
def test_admin_can_send_restart(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("restart", "admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_admin_can_send_camera_detect_set(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization(
|
||||
"front_door/detect/set", "admin", self.DEFAULT_SEPARATOR
|
||||
)
|
||||
)
|
||||
|
||||
def test_admin_can_send_camera_ptz(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("front_door/ptz", "admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
# --- Comma-separated roles ---
|
||||
|
||||
def test_comma_separated_admin_viewer_grants_admin(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("restart", "admin,viewer", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_comma_separated_viewer_admin_grants_admin(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("restart", "viewer,admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_comma_separated_with_spaces(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("restart", "viewer, admin", self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
# --- Custom separator ---
|
||||
|
||||
def test_pipe_separator(self):
|
||||
self.assertTrue(_check_ws_authorization("restart", "viewer|admin", "|"))
|
||||
|
||||
def test_pipe_separator_no_admin(self):
|
||||
self.assertFalse(_check_ws_authorization("restart", "viewer|editor", "|"))
|
||||
|
||||
# --- No role header (fail-closed) ---
|
||||
|
||||
def test_no_role_header_blocks_admin_topics(self):
|
||||
self.assertFalse(
|
||||
_check_ws_authorization("restart", None, self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
def test_no_role_header_allows_viewer_topics(self):
|
||||
self.assertTrue(
|
||||
_check_ws_authorization("onConnect", None, self.DEFAULT_SEPARATOR)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -773,9 +773,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
logger.debug(f"Camera {camera} disabled, skipping update")
|
||||
continue
|
||||
|
||||
camera_state = self.camera_states.get(camera)
|
||||
if camera_state is None:
|
||||
continue
|
||||
camera_state = self.camera_states[camera]
|
||||
|
||||
camera_state.update(
|
||||
frame_name, frame_time, current_tracked_objects, motion_boxes, regions
|
||||
|
||||
@@ -330,12 +330,7 @@ class TrackedObject:
|
||||
if self.obj_data["position_changes"] != obj_data["position_changes"]:
|
||||
significant_change = True
|
||||
|
||||
# disappearance of a per-frame attribute can be caused by detection
|
||||
# skipping the object on a frame (stationary objects on non-interval
|
||||
# frames), so only flag when a new attribute label appears
|
||||
prev_labels = {a["label"] for a in self.obj_data["attributes"]}
|
||||
curr_labels = {a["label"] for a in obj_data["attributes"]}
|
||||
if curr_labels - prev_labels:
|
||||
if self.obj_data["attributes"] != obj_data["attributes"]:
|
||||
significant_change = True
|
||||
|
||||
# if the state changed between stationary and active
|
||||
|
||||
+1
-123
@@ -2,9 +2,8 @@
|
||||
|
||||
import logging
|
||||
import subprocess as sp
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
|
||||
from frigate.const import PROCESS_PRIORITY_LOW
|
||||
from frigate.log import LogPipe
|
||||
|
||||
|
||||
@@ -47,124 +46,3 @@ def start_or_restart_ffmpeg(
|
||||
start_new_session=True,
|
||||
)
|
||||
return process
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def inject_progress_flags(cmd: list[str]) -> list[str]:
|
||||
"""Insert `-progress pipe:2 -nostats` immediately before the output path.
|
||||
|
||||
`-progress pipe:2` writes structured key=value lines to stderr;
|
||||
`-nostats` suppresses the noisy default stats output. The output path
|
||||
is conventionally the last token in an FFmpeg argv.
|
||||
"""
|
||||
if not cmd:
|
||||
return cmd
|
||||
return cmd[:-1] + ["-progress", "pipe:2", "-nostats", cmd[-1]]
|
||||
|
||||
|
||||
def run_ffmpeg_with_progress(
|
||||
cmd: list[str],
|
||||
*,
|
||||
expected_duration_seconds: float,
|
||||
on_progress: Optional[Callable[[float], None]] = None,
|
||||
stdin_payload: Optional[str] = None,
|
||||
process_started: Optional[Callable[[sp.Popen], None]] = None,
|
||||
use_low_priority: bool = True,
|
||||
) -> tuple[int, str]:
|
||||
"""Run an ffmpeg command, streaming progress via `-progress pipe:2`.
|
||||
|
||||
Args:
|
||||
cmd: ffmpeg argv. Output path must be the last token.
|
||||
expected_duration_seconds: Duration of the expected output clip in
|
||||
seconds. Used to convert ffmpeg's `out_time_us` into a percent.
|
||||
on_progress: Optional callback invoked with a percent in [0, 100].
|
||||
Called once with 0.0 at start, again on each `out_time_us=`
|
||||
stderr line, and once with 100.0 on `progress=end`.
|
||||
stdin_payload: Optional string written to ffmpeg stdin (used by
|
||||
export for concat playlists).
|
||||
process_started: Optional callback invoked with the live `Popen`
|
||||
once spawned — lets callers store the ref for cancellation.
|
||||
use_low_priority: When True, prepend `nice -n PROCESS_PRIORITY_LOW`
|
||||
so concat doesn't starve detection.
|
||||
|
||||
Returns:
|
||||
Tuple of `(returncode, captured_stderr)`. Stdout is left attached
|
||||
to the parent process to avoid buffer-full deadlocks.
|
||||
"""
|
||||
full_cmd = inject_progress_flags(cmd)
|
||||
if use_low_priority:
|
||||
full_cmd = ["nice", "-n", str(PROCESS_PRIORITY_LOW)] + full_cmd
|
||||
|
||||
def emit(percent: float) -> None:
|
||||
if on_progress is None:
|
||||
return
|
||||
try:
|
||||
on_progress(max(0.0, min(100.0, percent)))
|
||||
except Exception:
|
||||
logger.exception("FFmpeg progress callback failed")
|
||||
|
||||
emit(0.0)
|
||||
|
||||
proc = sp.Popen(
|
||||
full_cmd,
|
||||
stdin=sp.PIPE if stdin_payload is not None else None,
|
||||
stderr=sp.PIPE,
|
||||
text=True,
|
||||
encoding="ascii",
|
||||
errors="replace",
|
||||
)
|
||||
if process_started is not None:
|
||||
try:
|
||||
process_started(proc)
|
||||
except Exception:
|
||||
logger.exception("FFmpeg process_started callback failed")
|
||||
|
||||
if stdin_payload is not None and proc.stdin is not None:
|
||||
try:
|
||||
proc.stdin.write(stdin_payload)
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
proc.stdin.close()
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
|
||||
captured: list[str] = []
|
||||
if proc.stderr is not None:
|
||||
try:
|
||||
for raw_line in proc.stderr:
|
||||
captured.append(raw_line)
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("out_time_us="):
|
||||
if expected_duration_seconds <= 0:
|
||||
continue
|
||||
try:
|
||||
out_time_us = int(line.split("=", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if out_time_us < 0:
|
||||
continue
|
||||
out_seconds = out_time_us / 1_000_000.0
|
||||
emit((out_seconds / expected_duration_seconds) * 100.0)
|
||||
elif line == "progress=end":
|
||||
emit(100.0)
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("Failed reading FFmpeg progress stream")
|
||||
|
||||
proc.wait()
|
||||
|
||||
if proc.stderr is not None:
|
||||
try:
|
||||
remaining = proc.stderr.read()
|
||||
if remaining:
|
||||
captured.append(remaining)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return proc.returncode or 0, "".join(captured)
|
||||
|
||||
+125
-183
@@ -264,214 +264,156 @@ def get_amd_gpu_stats() -> Optional[dict[str, str]]:
|
||||
return results
|
||||
|
||||
|
||||
_INTEL_FDINFO_SAMPLE_SECONDS = 1.0
|
||||
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, str]]:
|
||||
"""Get stats using intel_gpu_top.
|
||||
|
||||
# Engines we track. Render/3D and Compute are pooled into "compute"; Video and
|
||||
# VideoEnhance into "dec" (VideoEnhance is the post-process engine that handles
|
||||
# VAAPI scaling/deinterlace/CSC, e.g. ffmpeg `-vf scale_vaapi=...`). The Copy
|
||||
# (DMA blitter) engine is intentionally ignored — it represents transparent
|
||||
# memory transfers, not user-visible GPU work.
|
||||
# i915 fdinfo keys (cumulative ns) → logical engine name.
|
||||
_I915_ENGINE_KEYS = {
|
||||
"drm-engine-render": "render",
|
||||
"drm-engine-video": "video",
|
||||
"drm-engine-video-enhance": "video-enhance",
|
||||
"drm-engine-compute": "compute",
|
||||
}
|
||||
# Xe fdinfo suffixes (cumulative cycles, paired with drm-total-cycles-*).
|
||||
_XE_ENGINE_KEYS = {
|
||||
"rcs": "render",
|
||||
"vcs": "video",
|
||||
"vecs": "video-enhance",
|
||||
"ccs": "compute",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_intel_gpu_pdev(device: Optional[str]) -> Optional[str]:
|
||||
"""Map a configured GPU hint (/dev/dri/card1, renderD128, or a PCI bus
|
||||
address) to its drm-pdev string so we can filter fdinfo entries to that
|
||||
device. Returns None when no hint is supplied or it cannot be resolved."""
|
||||
if not device:
|
||||
return None
|
||||
|
||||
if re.match(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]$", device):
|
||||
return device
|
||||
|
||||
name = os.path.basename(device.rstrip("/"))
|
||||
try:
|
||||
return os.path.basename(os.path.realpath(f"/sys/class/drm/{name}/device"))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _read_intel_drm_fdinfo(target_pdev: Optional[str]) -> dict:
|
||||
"""Snapshot DRM fdinfo for every Intel client visible in /proc.
|
||||
|
||||
Returns a dict keyed by (pdev, drm-client-id, pid) so the same context
|
||||
seen via multiple file descriptors on a single process collapses to one
|
||||
entry.
|
||||
Returns overall GPU usage derived from rc6 residency (idle time),
|
||||
plus individual engine breakdowns:
|
||||
- enc: Render/3D engine (compute/shader encoder, used by QSV)
|
||||
- dec: Video engines (fixed-function codec, used by VAAPI)
|
||||
"""
|
||||
snapshot: dict = {}
|
||||
|
||||
def get_stats_manually(output: str) -> dict[str, str]:
|
||||
"""Find global stats via regex when json fails to parse."""
|
||||
reading = "".join(output)
|
||||
results: dict[str, str] = {}
|
||||
|
||||
# rc6 residency for overall GPU usage
|
||||
rc6_match = re.search(r'"rc6":\{"value":([\d.]+)', reading)
|
||||
if rc6_match:
|
||||
rc6_value = float(rc6_match.group(1))
|
||||
results["gpu"] = f"{round(100.0 - rc6_value, 2)}%"
|
||||
else:
|
||||
results["gpu"] = "-%"
|
||||
|
||||
results["mem"] = "-%"
|
||||
|
||||
# Render/3D is the compute/encode engine
|
||||
render = []
|
||||
for result in re.findall(r'"Render/3D/0":{[a-z":\d.,%]+}', reading):
|
||||
packet = json.loads(result[14:])
|
||||
single = packet.get("busy", 0.0)
|
||||
render.append(float(single))
|
||||
|
||||
if render:
|
||||
results["compute"] = f"{round(sum(render) / len(render), 2)}%"
|
||||
|
||||
# Video engines are the fixed-function decode engines
|
||||
video = []
|
||||
for result in re.findall(r'"Video/\d":{[a-z":\d.,%]+}', reading):
|
||||
packet = json.loads(result[10:])
|
||||
single = packet.get("busy", 0.0)
|
||||
video.append(float(single))
|
||||
|
||||
if video:
|
||||
results["dec"] = f"{round(sum(video) / len(video), 2)}%"
|
||||
|
||||
return results
|
||||
|
||||
intel_gpu_top_command = [
|
||||
"timeout",
|
||||
"0.5s",
|
||||
"intel_gpu_top",
|
||||
"-J",
|
||||
"-o",
|
||||
"-",
|
||||
"-s",
|
||||
"1000", # Intel changed this from seconds to milliseconds in 2024+ versions
|
||||
]
|
||||
|
||||
if intel_gpu_device:
|
||||
intel_gpu_top_command += ["-d", intel_gpu_device]
|
||||
|
||||
try:
|
||||
proc_entries = os.listdir("/proc")
|
||||
except OSError:
|
||||
return snapshot
|
||||
p = sp.run(
|
||||
intel_gpu_top_command,
|
||||
encoding="ascii",
|
||||
capture_output=True,
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
for entry in proc_entries:
|
||||
if not entry.isdigit():
|
||||
continue
|
||||
# timeout has a non-zero returncode when timeout is reached
|
||||
if p.returncode != 124:
|
||||
logger.error(f"Unable to poll intel GPU stats: {p.stderr}")
|
||||
return None
|
||||
else:
|
||||
output = "".join(p.stdout.split())
|
||||
|
||||
fdinfo_dir = f"/proc/{entry}/fdinfo"
|
||||
try:
|
||||
fds = os.listdir(fdinfo_dir)
|
||||
except (FileNotFoundError, PermissionError, NotADirectoryError, OSError):
|
||||
continue
|
||||
data = json.loads(f"[{output}]")
|
||||
except json.JSONDecodeError:
|
||||
return get_stats_manually(output)
|
||||
|
||||
for fd in fds:
|
||||
try:
|
||||
with open(f"{fdinfo_dir}/{fd}") as f:
|
||||
content = f.read()
|
||||
except (FileNotFoundError, PermissionError, OSError):
|
||||
continue
|
||||
results: dict[str, str] = {}
|
||||
rc6_values = []
|
||||
render_global = []
|
||||
video_global = []
|
||||
# per-client: {pid: [total_busy_per_sample, ...]}
|
||||
client_usages: dict[str, list[float]] = {}
|
||||
|
||||
if "drm-driver" not in content:
|
||||
continue
|
||||
for block in data:
|
||||
# rc6 residency: percentage of time GPU is idle
|
||||
rc6 = block.get("rc6", {}).get("value")
|
||||
if rc6 is not None:
|
||||
rc6_values.append(float(rc6))
|
||||
|
||||
fields: dict[str, str] = {}
|
||||
for line in content.splitlines():
|
||||
key, sep, value = line.partition(":")
|
||||
if sep:
|
||||
fields[key.strip()] = value.strip()
|
||||
global_engine = block.get("engines")
|
||||
|
||||
driver = fields.get("drm-driver")
|
||||
if driver not in ("i915", "xe"):
|
||||
continue
|
||||
if global_engine:
|
||||
render_frame = global_engine.get("Render/3D/0", {}).get("busy")
|
||||
video_frame = global_engine.get("Video/0", {}).get("busy")
|
||||
|
||||
pdev = fields.get("drm-pdev", "")
|
||||
if target_pdev and pdev != target_pdev:
|
||||
continue
|
||||
if render_frame is not None:
|
||||
render_global.append(float(render_frame))
|
||||
|
||||
client_id = fields.get("drm-client-id")
|
||||
if not client_id:
|
||||
continue
|
||||
if video_frame is not None:
|
||||
video_global.append(float(video_frame))
|
||||
|
||||
key = (pdev, client_id, entry)
|
||||
if key in snapshot:
|
||||
continue
|
||||
clients = block.get("clients", {})
|
||||
|
||||
engines: dict[str, tuple[int, int]] = {}
|
||||
if clients:
|
||||
for client_block in clients.values():
|
||||
pid = client_block["pid"]
|
||||
|
||||
if driver == "i915":
|
||||
for fkey, engine in _I915_ENGINE_KEYS.items():
|
||||
raw = fields.get(fkey)
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
engines[engine] = (int(raw.split()[0]), 0)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
else:
|
||||
for suffix, engine in _XE_ENGINE_KEYS.items():
|
||||
busy_raw = fields.get(f"drm-cycles-{suffix}")
|
||||
total_raw = fields.get(f"drm-total-cycles-{suffix}")
|
||||
if not (busy_raw and total_raw):
|
||||
continue
|
||||
try:
|
||||
engines[engine] = (
|
||||
int(busy_raw.split()[0]),
|
||||
int(total_raw.split()[0]),
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if pid not in client_usages:
|
||||
client_usages[pid] = []
|
||||
|
||||
if not engines:
|
||||
continue
|
||||
# Sum all engine-class busy values for this client
|
||||
total_busy = 0.0
|
||||
for engine in client_block.get("engine-classes", {}).values():
|
||||
busy = engine.get("busy")
|
||||
if busy is not None:
|
||||
total_busy += float(busy)
|
||||
|
||||
snapshot[key] = {"driver": driver, "pid": entry, "engines": engines}
|
||||
client_usages[pid].append(total_busy)
|
||||
|
||||
return snapshot
|
||||
# Overall GPU usage from rc6 (idle) residency
|
||||
if rc6_values:
|
||||
rc6_avg = sum(rc6_values) / len(rc6_values)
|
||||
results["gpu"] = f"{round(100.0 - rc6_avg, 2)}%"
|
||||
|
||||
results["mem"] = "-%"
|
||||
|
||||
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, Any]]:
|
||||
"""Get stats by reading DRM fdinfo files.
|
||||
# Compute: Render/3D engine (compute/shader workloads and QSV encode)
|
||||
if render_global:
|
||||
results["compute"] = f"{round(sum(render_global) / len(render_global), 2)}%"
|
||||
|
||||
Each DRM client FD exposes monotonic per-engine busy counters via
|
||||
/proc/<pid>/fdinfo/<fd> (i915 since kernel 5.19, Xe since first release).
|
||||
We sample twice and divide busy-time deltas by wall-clock to derive
|
||||
utilization. Render/3D and Compute are pooled into "compute"; Video and
|
||||
VideoEnhance into "dec". Overall "gpu" is the sum of those pools (clamped
|
||||
to 100%).
|
||||
"""
|
||||
target_pdev = _resolve_intel_gpu_pdev(intel_gpu_device)
|
||||
# Decoder: Video engine (fixed-function codec)
|
||||
if video_global:
|
||||
results["dec"] = f"{round(sum(video_global) / len(video_global), 2)}%"
|
||||
|
||||
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
|
||||
if not snapshot_a:
|
||||
return None
|
||||
# Per-client GPU usage (sum of all engines per process)
|
||||
if client_usages:
|
||||
results["clients"] = {}
|
||||
|
||||
start = time.monotonic()
|
||||
time.sleep(_INTEL_FDINFO_SAMPLE_SECONDS)
|
||||
elapsed_ns = (time.monotonic() - start) * 1e9
|
||||
for pid, samples in client_usages.items():
|
||||
if samples:
|
||||
results["clients"][pid] = (
|
||||
f"{round(sum(samples) / len(samples), 2)}%"
|
||||
)
|
||||
|
||||
snapshot_b = _read_intel_drm_fdinfo(target_pdev)
|
||||
if not snapshot_b or elapsed_ns <= 0:
|
||||
return None
|
||||
|
||||
engine_pct: dict[str, float] = {
|
||||
"render": 0.0,
|
||||
"video": 0.0,
|
||||
"video-enhance": 0.0,
|
||||
"compute": 0.0,
|
||||
}
|
||||
pid_pct: dict[str, float] = {}
|
||||
|
||||
for key, data_b in snapshot_b.items():
|
||||
data_a = snapshot_a.get(key)
|
||||
if not data_a or data_a["driver"] != data_b["driver"]:
|
||||
continue
|
||||
|
||||
client_total = 0.0
|
||||
for engine, (busy_b, total_b) in data_b["engines"].items():
|
||||
if engine not in engine_pct:
|
||||
continue
|
||||
|
||||
busy_a, total_a = data_a["engines"].get(engine, (busy_b, total_b))
|
||||
|
||||
if data_b["driver"] == "i915":
|
||||
delta = max(0, busy_b - busy_a)
|
||||
pct = min(100.0, delta / elapsed_ns * 100.0)
|
||||
else:
|
||||
delta_busy = max(0, busy_b - busy_a)
|
||||
delta_total = total_b - total_a
|
||||
if delta_total <= 0:
|
||||
continue
|
||||
pct = min(100.0, delta_busy / delta_total * 100.0)
|
||||
|
||||
engine_pct[engine] += pct
|
||||
client_total += pct
|
||||
|
||||
pid_pct[data_b["pid"]] = pid_pct.get(data_b["pid"], 0.0) + client_total
|
||||
|
||||
for engine in engine_pct:
|
||||
engine_pct[engine] = min(100.0, engine_pct[engine])
|
||||
|
||||
compute_pct = min(100.0, engine_pct["render"] + engine_pct["compute"])
|
||||
dec_pct = min(100.0, engine_pct["video"] + engine_pct["video-enhance"])
|
||||
overall_pct = min(100.0, compute_pct + dec_pct)
|
||||
|
||||
results: dict[str, Any] = {
|
||||
"gpu": f"{round(overall_pct, 2)}%",
|
||||
"mem": "-%",
|
||||
"compute": f"{round(compute_pct, 2)}%",
|
||||
"dec": f"{round(dec_pct, 2)}%",
|
||||
}
|
||||
|
||||
if pid_pct:
|
||||
results["clients"] = {
|
||||
pid: f"{round(min(100.0, pct), 2)}%" for pid, pct in pid_pct.items()
|
||||
}
|
||||
|
||||
return results
|
||||
return results
|
||||
|
||||
|
||||
def get_openvino_npu_stats() -> Optional[dict[str, str]]:
|
||||
|
||||
+21
-19
@@ -438,32 +438,34 @@ def process_frames(
|
||||
else:
|
||||
object_tracker.update_frame_times(frame_name, frame_time)
|
||||
|
||||
# group the attribute detections based on what label they apply to
|
||||
attribute_detections: dict[str, list[TrackedObjectAttribute]] = {}
|
||||
for label, attribute_labels in attributes_map.items():
|
||||
attribute_detections[label] = [
|
||||
TrackedObjectAttribute(d)
|
||||
for d in consolidated_detections
|
||||
if d[0] in attribute_labels
|
||||
]
|
||||
|
||||
# build detections
|
||||
detections = {}
|
||||
for obj in object_tracker.tracked_objects.values():
|
||||
detections[obj["id"]] = {**obj, "attributes": []}
|
||||
|
||||
# assign each detected attribute to the best matching object.
|
||||
# iterate consolidated_detections once so attributes that appear under
|
||||
# multiple parent labels in attributes_map (e.g. license_plate is in
|
||||
# both "car" and "motorcycle") are not appended more than once
|
||||
# find the best object for each attribute to be assigned to
|
||||
all_objects: list[dict[str, Any]] = object_tracker.tracked_objects.values()
|
||||
detected_attributes = [
|
||||
TrackedObjectAttribute(d)
|
||||
for d in consolidated_detections
|
||||
if d[0] in all_attributes
|
||||
]
|
||||
for attribute in detected_attributes:
|
||||
filtered_objects = filter(
|
||||
lambda o: attribute.label in attributes_map.get(o["label"], []),
|
||||
all_objects,
|
||||
)
|
||||
selected_object_id = attribute.find_best_object(filtered_objects)
|
||||
|
||||
if selected_object_id is not None:
|
||||
detections[selected_object_id]["attributes"].append(
|
||||
attribute.get_tracking_data()
|
||||
for attributes in attribute_detections.values():
|
||||
for attribute in attributes:
|
||||
filtered_objects = filter(
|
||||
lambda o: attribute.label in attributes_map.get(o["label"], []),
|
||||
all_objects,
|
||||
)
|
||||
selected_object_id = attribute.find_best_object(filtered_objects)
|
||||
|
||||
if selected_object_id is not None:
|
||||
detections[selected_object_id]["attributes"].append(
|
||||
attribute.get_tracking_data()
|
||||
)
|
||||
|
||||
# debug object tracking
|
||||
if False:
|
||||
|
||||
@@ -174,7 +174,6 @@ class CameraWatchdog(threading.Thread):
|
||||
)
|
||||
self.requestor = InterProcessRequestor()
|
||||
self.was_enabled = self.config.enabled
|
||||
self.was_record_enabled_in_config = self.config.record.enabled_in_config
|
||||
|
||||
self.segment_subscriber = RecordingsDataSubscriber(RecordingsDataTypeEnum.all)
|
||||
self.latest_valid_segment_time: float = 0
|
||||
@@ -324,22 +323,6 @@ class CameraWatchdog(threading.Thread):
|
||||
self.was_enabled = enabled
|
||||
continue
|
||||
|
||||
record_enabled_in_config = self.config.record.enabled_in_config
|
||||
if record_enabled_in_config != self.was_record_enabled_in_config:
|
||||
if record_enabled_in_config and enabled:
|
||||
self.logger.debug(
|
||||
f"Record enabled in config for {self.config.name}, restarting ffmpeg"
|
||||
)
|
||||
self.stop_all_ffmpeg()
|
||||
self.start_all_ffmpeg()
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_enable_time = datetime.now().astimezone(timezone.utc)
|
||||
last_restart_time = datetime.now().timestamp()
|
||||
self.was_record_enabled_in_config = record_enabled_in_config
|
||||
continue
|
||||
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,95 +1,88 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "runtime-notice"
|
||||
},
|
||||
"source": [
|
||||
"**Before running:** go to **Runtime → Change runtime type → Fallback runtime version: 2025.07** (Python 3.11). The current Colab default (Python 3.12+) is incompatible with `super-gradients`."
|
||||
]
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "rmuF9iKWTbdk"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"! pip install -q git+https://github.com/Deci-AI/super-gradients.git"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "NiRCt917KKcL"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.12/dist-packages/super_gradients/training/pretrained_models.py\n",
|
||||
"! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.12/dist-packages/super_gradients/training/utils/checkpoint_utils.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "dTB0jy_NNSFz"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from super_gradients.common.object_names import Models\n",
|
||||
"from super_gradients.conversion import DetectionOutputFormatMode\n",
|
||||
"from super_gradients.training import models\n",
|
||||
"\n",
|
||||
"model = models.get(Models.YOLO_NAS_S, pretrained_weights=\"coco\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "GymUghyCNXem"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# export the model for compatibility with Frigate\n",
|
||||
"\n",
|
||||
"model.export(\"yolo_nas_s.onnx\",\n",
|
||||
" output_predictions_format=DetectionOutputFormatMode.FLAT_FORMAT,\n",
|
||||
" max_predictions_per_image=20,\n",
|
||||
" num_pre_nms_predictions=300,\n",
|
||||
" confidence_threshold=0.4,\n",
|
||||
" input_image_shape=(320,320),\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "uBhXV5g4Nh42"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from google.colab import files\n",
|
||||
"\n",
|
||||
"files.download('yolo_nas_s.onnx')"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"colab": {
|
||||
"provenance": []
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "rmuF9iKWTbdk"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"! pip install -q \"jedi>=0.16\"\n",
|
||||
"! pip install -q git+https://github.com/Deci-AI/super-gradients.git"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "NiRCt917KKcL"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": "! sed -i 's/sghub\\.deci\\.ai/d2gjn4b69gu75n.cloudfront.net/g; s/sg-hub-nv\\.s3\\.amazonaws\\.com/d2gjn4b69gu75n.cloudfront.net/g' /usr/local/lib/python*/dist-packages/super_gradients/training/pretrained_models.py\n! sed -i 's/sghub\\.deci\\.ai/d2gjn4b69gu75n.cloudfront.net/g; s/sg-hub-nv\\.s3\\.amazonaws\\.com/d2gjn4b69gu75n.cloudfront.net/g' /usr/local/lib/python*/dist-packages/super_gradients/training/utils/checkpoint_utils.py"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "dTB0jy_NNSFz"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from super_gradients.common.object_names import Models\n",
|
||||
"from super_gradients.conversion import DetectionOutputFormatMode\n",
|
||||
"from super_gradients.training import models\n",
|
||||
"\n",
|
||||
"model = models.get(Models.YOLO_NAS_S, pretrained_weights=\"coco\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "GymUghyCNXem"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# export the model for compatibility with Frigate\n",
|
||||
"\n",
|
||||
"model.export(\"yolo_nas_s.onnx\",\n",
|
||||
" output_predictions_format=DetectionOutputFormatMode.FLAT_FORMAT,\n",
|
||||
" max_predictions_per_image=20,\n",
|
||||
" num_pre_nms_predictions=300,\n",
|
||||
" confidence_threshold=0.4,\n",
|
||||
" input_image_shape=(320,320),\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "uBhXV5g4Nh42"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from google.colab import files\n",
|
||||
"\n",
|
||||
"files.download('yolo_nas_s.onnx')"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"colab": {
|
||||
"provenance": []
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0
|
||||
}
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0
|
||||
}
|
||||
|
||||
@@ -69,18 +69,17 @@ test.describe("Navigation — conditional items @critical", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("/chat is hidden when no agent has the chat role (desktop)", async ({
|
||||
test("/chat is hidden when genai.model is none (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
genai: {
|
||||
descriptions_only: {
|
||||
provider: "ollama",
|
||||
model: "llava",
|
||||
roles: ["descriptions"],
|
||||
},
|
||||
enabled: false,
|
||||
provider: "ollama",
|
||||
model: "none",
|
||||
base_url: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -90,20 +89,12 @@ test.describe("Navigation — conditional items @critical", () => {
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("/chat is visible when an agent has the chat role (desktop)", async ({
|
||||
test("/chat is visible when genai.model is set (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
genai: {
|
||||
chat_agent: {
|
||||
provider: "ollama",
|
||||
model: "llava",
|
||||
roles: ["chat"],
|
||||
},
|
||||
},
|
||||
},
|
||||
config: { genai: { enabled: true, model: "llava" } },
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
await expect(
|
||||
|
||||
@@ -31,7 +31,7 @@ test.describe("Replay — no active session @medium", () => {
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: /No Active Debug Replay Session/i,
|
||||
name: /No Active Replay Session/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
const goButton = frigateApp.page.getByRole("button", {
|
||||
@@ -48,7 +48,7 @@ test.describe("Replay — no active session @medium", () => {
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: /No Active Debug Replay Session/i,
|
||||
name: /No Active Replay Session/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await frigateApp.page
|
||||
@@ -297,7 +297,7 @@ test.describe("Replay — mobile @medium @mobile", () => {
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", {
|
||||
level: 2,
|
||||
name: /No Active Debug Replay Session/i,
|
||||
name: /No Active Replay Session/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
Generated
+297
-279
@@ -103,7 +103,7 @@
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"@vitest/coverage-v8": "^3.0.7",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
@@ -704,61 +704,136 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
|
||||
"integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": "^3.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/regexpp": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
|
||||
"integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
|
||||
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.4",
|
||||
"debug": "^4.3.2",
|
||||
"espree": "^9.6.0",
|
||||
"globals": "^13.19.0",
|
||||
"ignore": "^5.2.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"minimatch": "^3.1.2",
|
||||
"strip-json-comments": "^3.1.1"
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
|
||||
"integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
|
||||
"node_modules/@eslint-community/regexpp": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
|
||||
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
|
||||
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^3.0.5",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^10.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
|
||||
"integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
|
||||
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
|
||||
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
|
||||
"integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^1.2.1",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
@@ -808,19 +883,42 @@
|
||||
"react-hook-form": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.11.14",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
|
||||
"integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanwhocodes/object-schema": "^2.0.2",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.0.5"
|
||||
"@humanfs/types": "^0.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.10.0"
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/node": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
|
||||
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.2",
|
||||
"@humanfs/types": "^0.15.0",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/types": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
|
||||
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
@@ -836,12 +934,19 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/object-schema": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
|
||||
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
|
||||
"node_modules/@humanwhocodes/retry": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
|
||||
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/ansi": {
|
||||
"version": "1.0.2",
|
||||
@@ -5693,6 +5798,13 @@
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -6194,10 +6306,11 @@
|
||||
"integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA=="
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.11.3",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
||||
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6242,9 +6355,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6639,16 +6752,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -7342,19 +7445,6 @@
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"esutils": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
|
||||
@@ -7523,59 +7613,59 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "8.57.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
|
||||
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
|
||||
"integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
"@eslint/eslintrc": "^2.1.4",
|
||||
"@eslint/js": "8.57.0",
|
||||
"@humanwhocodes/config-array": "^0.11.14",
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@eslint/config-array": "^0.23.5",
|
||||
"@eslint/config-helpers": "^0.5.5",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
"@ungap/structured-clone": "^1.2.0",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.2",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"ajv": "^6.14.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"debug": "^4.3.2",
|
||||
"doctrine": "^3.0.0",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^7.2.2",
|
||||
"eslint-visitor-keys": "^3.4.3",
|
||||
"espree": "^9.6.1",
|
||||
"esquery": "^1.4.2",
|
||||
"eslint-scope": "^9.1.2",
|
||||
"eslint-visitor-keys": "^5.0.1",
|
||||
"espree": "^11.2.0",
|
||||
"esquery": "^1.7.0",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"file-entry-cache": "^6.0.1",
|
||||
"file-entry-cache": "^8.0.0",
|
||||
"find-up": "^5.0.0",
|
||||
"glob-parent": "^6.0.2",
|
||||
"globals": "^13.19.0",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^5.2.0",
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"is-path-inside": "^3.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"levn": "^0.4.1",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"minimatch": "^3.1.2",
|
||||
"minimatch": "^10.2.4",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"text-table": "^0.2.0"
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
"bin": {
|
||||
"eslint": "bin/eslint.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
"url": "https://eslint.org/donate"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jiti": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"jiti": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-prettier": {
|
||||
@@ -7699,17 +7789,19 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
|
||||
"integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/esrecurse": "^4.3.1",
|
||||
"@types/estree": "^1.0.8",
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
@@ -7727,29 +7819,95 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"acorn": "^8.9.0",
|
||||
"acorn": "^8.16.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
"eslint-visitor-keys": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/espree/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
|
||||
"integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"estraverse": "^5.1.0"
|
||||
},
|
||||
@@ -7775,6 +7933,7 @@
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
@@ -8015,16 +8174,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
"integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
"integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flat-cache": "^3.0.4"
|
||||
"flat-cache": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
@@ -8077,18 +8236,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||
"integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
"integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flatted": "^3.2.9",
|
||||
"keyv": "^4.5.3",
|
||||
"rimraf": "^3.0.2"
|
||||
"keyv": "^4.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
@@ -8340,26 +8498,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -8371,22 +8509,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "13.24.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globby": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
|
||||
@@ -8939,23 +9061,6 @@
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parent-module": "^1.0.0",
|
||||
"resolve-from": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -9176,15 +9281,6 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-path-inside": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
|
||||
"integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-obj": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
|
||||
@@ -9647,12 +9743,6 @@
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/log-symbols": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||
@@ -11301,19 +11391,6 @@
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"callsites": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-entities": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||
@@ -12512,16 +12589,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||
@@ -12564,23 +12631,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||
@@ -13092,19 +13142,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/style-to-js": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||
@@ -13445,12 +13482,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -13684,19 +13715,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
"@vitest/coverage-v8": "^3.0.7",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"description": "Enabled"
|
||||
},
|
||||
"audio": {
|
||||
"label": "Audio detection",
|
||||
"label": "Audio events",
|
||||
"description": "Settings for audio-based event detection for this camera.",
|
||||
"enabled": {
|
||||
"label": "Enable audio detection",
|
||||
@@ -485,10 +485,6 @@
|
||||
"hwaccel_args": {
|
||||
"label": "Export hwaccel args",
|
||||
"description": "Hardware acceleration args to use for export/transcode operations."
|
||||
},
|
||||
"max_concurrent": {
|
||||
"label": "Maximum concurrent exports",
|
||||
"description": "Maximum number of export jobs to process at the same time."
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
|
||||
@@ -242,8 +242,8 @@
|
||||
"description": "Enable per-process network bandwidth monitoring for camera ffmpeg processes and detectors (requires capabilities)."
|
||||
},
|
||||
"intel_gpu_device": {
|
||||
"label": "Intel GPU device",
|
||||
"description": "PCI bus address or DRM device path (e.g. /dev/dri/card1) used to pin Intel GPU stats to a specific device when multiple are present."
|
||||
"label": "SR-IOV device",
|
||||
"description": "Device identifier used when treating Intel GPUs as SR-IOV to fix GPU stats."
|
||||
}
|
||||
},
|
||||
"version_check": {
|
||||
@@ -539,7 +539,7 @@
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"label": "Audio detection",
|
||||
"label": "Audio events",
|
||||
"description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.",
|
||||
"enabled": {
|
||||
"label": "Enable audio detection",
|
||||
@@ -1000,10 +1000,6 @@
|
||||
"hwaccel_args": {
|
||||
"label": "Export hwaccel args",
|
||||
"description": "Hardware acceleration args to use for export/transcode operations."
|
||||
},
|
||||
"max_concurrent": {
|
||||
"label": "Maximum concurrent exports",
|
||||
"description": "Maximum number of export jobs to process at the same time."
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
|
||||
@@ -70,8 +70,7 @@
|
||||
},
|
||||
"recording": {
|
||||
"enable": "Enable Recording",
|
||||
"disable": "Disable Recording",
|
||||
"disabledInConfig": "Recording must first be enabled in Settings for this camera."
|
||||
"disable": "Disable Recording"
|
||||
},
|
||||
"snapshots": {
|
||||
"enable": "Enable Snapshots",
|
||||
|
||||
@@ -19,31 +19,26 @@
|
||||
"startLabel": "Start",
|
||||
"endLabel": "End",
|
||||
"toast": {
|
||||
"success": "Debug replay started successfully",
|
||||
"error": "Failed to start debug replay: {{error}}",
|
||||
"alreadyActive": "A replay session is already active",
|
||||
"stopped": "Debug replay stopped",
|
||||
"stopError": "Failed to stop debug replay: {{error}}",
|
||||
"goToReplay": "Go to Replay"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
"noSession": "No Active Debug Replay Session",
|
||||
"noSessionDesc": "Start a Debug Replay from History view by clicking the Actions button in the toolbar and choosing Debug Replay.",
|
||||
"noSession": "No Active Replay Session",
|
||||
"noSessionDesc": "Start a debug replay from the History view by clicking the Debug Replay button in the toolbar.",
|
||||
"goToRecordings": "Go to History",
|
||||
"preparingClip": "Preparing clip…",
|
||||
"preparingClipDesc": "Frigate is stitching together recordings for the selected time range. This can take a minute for longer ranges.",
|
||||
"startingCamera": "Starting Debug Replay…",
|
||||
"startError": {
|
||||
"title": "Failed to start Debug Replay",
|
||||
"back": "Back to History"
|
||||
},
|
||||
"sourceCamera": "Source Camera",
|
||||
"replayCamera": "Replay Camera",
|
||||
"initializingReplay": "Initializing Debug Replay...",
|
||||
"stoppingReplay": "Stopping Debug Replay...",
|
||||
"initializingReplay": "Initializing replay...",
|
||||
"stoppingReplay": "Stopping replay...",
|
||||
"stopReplay": "Stop Replay",
|
||||
"confirmStop": {
|
||||
"title": "Stop Debug Replay?",
|
||||
"description": "This will stop the session and clean up all temporary data. Are you sure?",
|
||||
"description": "This will stop the replay session and clean up all temporary data. Are you sure?",
|
||||
"confirm": "Stop Replay",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
@@ -54,6 +49,6 @@
|
||||
"activeTracking": "Active tracking",
|
||||
"noActiveTracking": "No active tracking",
|
||||
"configuration": "Configuration",
|
||||
"configurationDesc": "Fine tune motion detection and object tracking settings for the Debug Replay camera. No changes are saved to your Frigate configuration file."
|
||||
"configurationDesc": "Fine tune motion detection and object tracking settings for the debug replay camera. No changes are saved to your Frigate configuration file."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,18 +20,7 @@
|
||||
"overriddenGlobal": "Overridden (Global)",
|
||||
"overriddenGlobalTooltip": "This camera overrides global configuration settings in this section",
|
||||
"overriddenBaseConfig": "Overridden (Base Config)",
|
||||
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section",
|
||||
"overriddenInCameras": {
|
||||
"label_one": "Overridden in {{count}} camera",
|
||||
"label_other": "Overridden in {{count}} cameras",
|
||||
"tooltip_one": "{{count}} camera overrides values in this section. Click to see details.",
|
||||
"tooltip_other": "{{count}} cameras override values in this section. Click to see details.",
|
||||
"heading_one": "This global section has fields that are overridden in {{count}} camera.",
|
||||
"heading_other": "This global section has fields that are overridden in {{count}} cameras.",
|
||||
"othersField_one": "{{count}} other",
|
||||
"othersField_other": "{{count}} others",
|
||||
"profilePrefix": "{{profile}} profile: {{fields}}"
|
||||
}
|
||||
"overriddenBaseConfigTooltip": "The {{profile}} profile overrides configuration settings in this section"
|
||||
},
|
||||
"menu": {
|
||||
"general": "General",
|
||||
@@ -49,7 +38,7 @@
|
||||
"globalMotion": "Motion detection",
|
||||
"globalObjects": "Objects",
|
||||
"globalReview": "Review",
|
||||
"globalAudioEvents": "Audio detection",
|
||||
"globalAudioEvents": "Audio events",
|
||||
"globalLivePlayback": "Live playback",
|
||||
"globalTimestampStyle": "Timestamp style",
|
||||
"systemDatabase": "Database",
|
||||
@@ -80,7 +69,7 @@
|
||||
"cameraMotion": "Motion detection",
|
||||
"cameraObjects": "Objects",
|
||||
"cameraConfigReview": "Review",
|
||||
"cameraAudioEvents": "Audio detection",
|
||||
"cameraAudioEvents": "Audio events",
|
||||
"cameraAudioTranscription": "Audio transcription",
|
||||
"cameraNotifications": "Notifications",
|
||||
"cameraLivePlayback": "Live playback",
|
||||
@@ -512,14 +501,6 @@
|
||||
"inherit": "Inherit",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"cameraType": {
|
||||
"title": "Camera Type",
|
||||
"label": "Camera type",
|
||||
"description": "Set the type for each camera. Dedicated LPR cameras are single-purpose cameras with powerful optical zoom to capture license plates on distant vehicles. Most cameras should use the normal camera type unless the camera is specifically for LPR and has a tightly focused view on license plates.",
|
||||
"normal": "Normal",
|
||||
"dedicatedLpr": "Dedicated LPR",
|
||||
"saveSuccess": "Updated camera type for {{cameraName}}. Restart Frigate to apply the changes."
|
||||
}
|
||||
},
|
||||
"cameraReview": {
|
||||
@@ -1651,8 +1632,7 @@
|
||||
"review": {
|
||||
"recordDisabled": "Recording is disabled, review items will not be generated.",
|
||||
"detectDisabled": "Object detection is disabled. Review items require detected objects to categorize alerts and detections.",
|
||||
"allNonAlertDetections": "All non-alert activity will be included as detections.",
|
||||
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images."
|
||||
"allNonAlertDetections": "All non-alert activity will be included as detections."
|
||||
},
|
||||
"audio": {
|
||||
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
|
||||
@@ -1661,21 +1641,15 @@
|
||||
"audioDetectionDisabled": "Audio detection is not enabled for this camera. Audio transcription requires audio detection to be active."
|
||||
},
|
||||
"detect": {
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function."
|
||||
},
|
||||
"objects": {
|
||||
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended."
|
||||
},
|
||||
"faceRecognition": {
|
||||
"globalDisabled": "The face recognition enrichment must be enabled for face recognition features to function on this camera.",
|
||||
"personNotTracked": "Face recognition requires the 'person' object to be tracked. Enable 'person' in Objects for this camera.",
|
||||
"modelSizeLarge": "The 'large' model requires a GPU or NPU for reasonable performance. Use 'small' on CPU-only systems."
|
||||
"globalDisabled": "Face recognition is not enabled at the global level. Enable it in global settings for camera-level face recognition to function.",
|
||||
"personNotTracked": "Face recognition requires the 'person' object to be tracked. Ensure 'person' is in the object tracking list."
|
||||
},
|
||||
"lpr": {
|
||||
"globalDisabled": "The license plate recognition enrichment must be enabled for LPR features to function on this camera.",
|
||||
"vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked. Enable 'car' or 'motorcycle' in Objects for this camera.",
|
||||
"modelSizeLarge": "The 'large' model is optimized for multi-line license plates. The 'small' model provides better performance over 'large' and should be used unless your region uses multi-line plate formats."
|
||||
"globalDisabled": "License plate recognition is not enabled at the global level. Enable it in global settings for camera-level LPR to function.",
|
||||
"vehicleNotTracked": "License plate recognition requires 'car' or 'motorcycle' to be tracked."
|
||||
},
|
||||
"record": {
|
||||
"noRecordRole": "No streams have the record role defined. Recording will not function."
|
||||
@@ -1689,9 +1663,6 @@
|
||||
"detectors": {
|
||||
"mixedTypes": "All detectors must use the same type. Remove existing detectors to use a different type.",
|
||||
"mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}."
|
||||
},
|
||||
"semanticSearch": {
|
||||
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,9 +177,6 @@
|
||||
}
|
||||
},
|
||||
"framesAndDetections": "Frames / Detections",
|
||||
"noCameras": {
|
||||
"title": "No Cameras Found"
|
||||
},
|
||||
"label": {
|
||||
"camera": "camera",
|
||||
"detect": "detect",
|
||||
|
||||
@@ -56,14 +56,7 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
}
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
}
|
||||
wsRef.current?.close();
|
||||
resetWsStore();
|
||||
};
|
||||
}, [wsUrl]);
|
||||
|
||||
@@ -10,11 +10,7 @@ const audioTranscription: SectionConfigOverrides = {
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
return (
|
||||
!ctx.fullCameraConfig.ffmpeg?.inputs?.some((input) =>
|
||||
input.roles?.includes("audio"),
|
||||
) || ctx.fullCameraConfig.audio.enabled === false
|
||||
);
|
||||
return ctx.fullCameraConfig.audio.enabled === false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
@@ -3,15 +3,6 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const detect: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/camera_specific",
|
||||
messages: [
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.detect.disabled",
|
||||
severity: "info",
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" && ctx.formData?.enabled === false,
|
||||
},
|
||||
],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "fps-greater-than-five",
|
||||
@@ -21,7 +12,6 @@ const detect: SectionConfigOverrides = {
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
if (ctx.fullCameraConfig.type === "lpr") return false;
|
||||
const detectFps = ctx.formData?.fps as number | undefined;
|
||||
const streamFps = ctx.fullCameraConfig.detect?.fps;
|
||||
return detectFps != null && streamFps != null && detectFps > 5;
|
||||
|
||||
@@ -53,21 +53,6 @@ const faceRecognition: SectionConfigOverrides = {
|
||||
"device",
|
||||
],
|
||||
restartRequired: ["enabled", "model_size", "device"],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "model-size-large",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.faceRecognition.modelSizeLarge",
|
||||
severity: "info",
|
||||
position: "after",
|
||||
condition: (ctx) => ctx.formData?.model_size === "large",
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
model_size: {
|
||||
"ui:options": { size: "xs" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -65,16 +65,6 @@ const lpr: SectionConfigOverrides = {
|
||||
"replace_rules",
|
||||
],
|
||||
restartRequired: ["model_size", "enhancement", "device"],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "model-size-large",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.lpr.modelSizeLarge",
|
||||
severity: "info",
|
||||
position: "after",
|
||||
condition: (ctx) => ctx.formData?.model_size === "large",
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
format: {
|
||||
"ui:options": { size: "md" },
|
||||
@@ -93,9 +83,6 @@ const lpr: SectionConfigOverrides = {
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
model_size: {
|
||||
"ui:options": { size: "xs" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,42 +1,8 @@
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon,
|
||||
// etc.) are populated into objects.filters by the backend even when the
|
||||
// model can't actually detect them. They aren't user-settable, so hide any
|
||||
// `filters.<attr>` patterns from forms and override comparisons.
|
||||
const hideAttributeFilters = (config: FrigateConfig): string[] =>
|
||||
(config.model?.all_attributes ?? []).map((attr) => `filters.${attr}`);
|
||||
|
||||
const objects: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/object_filters",
|
||||
messages: [
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.detect.disabled",
|
||||
severity: "info",
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" &&
|
||||
ctx.fullCameraConfig?.detect?.enabled === false,
|
||||
},
|
||||
],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "genai-no-descriptions-provider",
|
||||
field: "genai.enabled",
|
||||
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
||||
severity: "warning",
|
||||
position: "before",
|
||||
condition: (ctx) => {
|
||||
const providers = ctx.fullConfig.genai;
|
||||
if (!providers || Object.keys(providers).length === 0) return true;
|
||||
return !Object.values(providers).some((agent) =>
|
||||
agent.roles?.includes("descriptions"),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldDocs: {
|
||||
"filters.min_area": "/configuration/object_filters#object-area",
|
||||
"filters.max_area": "/configuration/object_filters#object-area",
|
||||
@@ -60,7 +26,6 @@ const objects: SectionConfigOverrides = {
|
||||
"filters.*.raw_mask",
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
hideAttributeFilters,
|
||||
],
|
||||
advancedFields: ["genai"],
|
||||
uiSchema: {
|
||||
@@ -134,7 +99,6 @@ const objects: SectionConfigOverrides = {
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
"genai.required_zones",
|
||||
hideAttributeFilters,
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
@@ -159,7 +123,6 @@ const objects: SectionConfigOverrides = {
|
||||
"filters.*.raw_mask",
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
hideAttributeFilters,
|
||||
],
|
||||
advancedFields: [],
|
||||
},
|
||||
|
||||
@@ -41,38 +41,6 @@ const review: SectionConfigOverrides = {
|
||||
return !Array.isArray(labels) || labels.length === 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "genai-no-descriptions-provider",
|
||||
field: "genai.enabled",
|
||||
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
||||
severity: "warning",
|
||||
position: "before",
|
||||
condition: (ctx) => {
|
||||
const providers = ctx.fullConfig.genai;
|
||||
if (!providers || Object.keys(providers).length === 0) return true;
|
||||
return !Object.values(providers).some((agent) =>
|
||||
agent.roles?.includes("descriptions"),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "genai-image-source-recordings-record-disabled",
|
||||
field: "genai.image_source",
|
||||
messageKey:
|
||||
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
const genai = ctx.formData?.genai as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (genai?.image_source !== "recordings") return false;
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
return ctx.fullCameraConfig.record?.enabled === false;
|
||||
}
|
||||
return ctx.fullConfig.record?.enabled === false;
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldDocs: {
|
||||
"alerts.labels": "/configuration/review/#alerts-and-detections",
|
||||
|
||||
@@ -18,18 +18,6 @@ const semanticSearch: SectionConfigOverrides = {
|
||||
advancedFields: ["reindex", "device"],
|
||||
restartRequired: ["enabled", "model", "model_size", "device"],
|
||||
hiddenFields: ["reindex"],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "jinav2-small-model-size",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
condition: (ctx) =>
|
||||
ctx.formData?.model === "jinav2" &&
|
||||
ctx.formData?.model_size === "small",
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
model: {
|
||||
"ui:widget": "semanticSearchModel",
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useRef,
|
||||
useContext,
|
||||
} from "react";
|
||||
import useSWR, { mutate as swrMutate } from "swr";
|
||||
import useSWR from "swr";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -22,11 +22,9 @@ import {
|
||||
modifySchemaForSection,
|
||||
getEffectiveDefaultsForSection,
|
||||
sanitizeOverridesForSection,
|
||||
synthesizeMissingObjectFilters,
|
||||
} from "./section-special-cases";
|
||||
import { getSectionValidation } from "../section-validations";
|
||||
import { useConfigOverride } from "@/hooks/use-config-override";
|
||||
import { CameraOverridesBadge } from "./CameraOverridesBadge";
|
||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -59,11 +57,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ConfigSectionData,
|
||||
HiddenFieldEntry,
|
||||
JsonValue,
|
||||
} from "@/types/configForm";
|
||||
import { ConfigSectionData, JsonValue } from "@/types/configForm";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import {
|
||||
@@ -73,7 +67,6 @@ import {
|
||||
buildConfigDataForPath,
|
||||
flattenOverrides,
|
||||
getBaseCameraSectionValue,
|
||||
resolveHiddenFieldEntries,
|
||||
sanitizeSectionData as sharedSanitizeSectionData,
|
||||
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
|
||||
} from "@/utils/configUtil";
|
||||
@@ -96,7 +89,7 @@ export interface SectionConfig {
|
||||
/** Fields to group together */
|
||||
fieldGroups?: Record<string, string[]>;
|
||||
/** Fields to hide from UI */
|
||||
hiddenFields?: HiddenFieldEntry[];
|
||||
hiddenFields?: string[];
|
||||
/** Fields to show in advanced section */
|
||||
advancedFields?: string[];
|
||||
/** Fields to compare for override detection */
|
||||
@@ -363,34 +356,25 @@ export function ConfigSection({
|
||||
return get(config, sectionPath);
|
||||
}, [config, cameraName, sectionPath, effectiveLevel, profileName]);
|
||||
|
||||
const rawFormData = useMemo<ConfigSectionData>(() => {
|
||||
const rawFormData = useMemo(() => {
|
||||
if (!config) return {};
|
||||
|
||||
if (rawSectionValue === undefined || rawSectionValue === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return synthesizeMissingObjectFilters(
|
||||
sectionPath,
|
||||
rawSectionValue,
|
||||
modifiedSchema ?? undefined,
|
||||
) as ConfigSectionData;
|
||||
}, [config, rawSectionValue, sectionPath, modifiedSchema]);
|
||||
return rawSectionValue;
|
||||
}, [config, rawSectionValue]);
|
||||
|
||||
// When editing a profile, hide fields that require a restart since they
|
||||
// cannot take effect via profile switching alone.
|
||||
const effectiveHiddenFields = useMemo(() => {
|
||||
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, config);
|
||||
if (!profileName || !sectionConfig.restartRequired?.length) {
|
||||
return base;
|
||||
return sectionConfig.hiddenFields;
|
||||
}
|
||||
const base = sectionConfig.hiddenFields ?? [];
|
||||
return [...new Set([...base, ...sectionConfig.restartRequired])];
|
||||
}, [
|
||||
profileName,
|
||||
sectionConfig.hiddenFields,
|
||||
sectionConfig.restartRequired,
|
||||
config,
|
||||
]);
|
||||
}, [profileName, sectionConfig.hiddenFields, sectionConfig.restartRequired]);
|
||||
|
||||
const sanitizeSectionData = useCallback(
|
||||
(data: ConfigSectionData) =>
|
||||
@@ -402,7 +386,7 @@ export function ConfigSection({
|
||||
const baseData = modifiedSchema
|
||||
? applySchemaDefaults(modifiedSchema, rawFormData)
|
||||
: rawFormData;
|
||||
return sanitizeSectionData(baseData as ConfigSectionData);
|
||||
return sanitizeSectionData(baseData);
|
||||
}, [rawFormData, modifiedSchema, sanitizeSectionData]);
|
||||
|
||||
const baselineSnapshot = useMemo(() => {
|
||||
@@ -758,7 +742,6 @@ export function ConfigSection({
|
||||
}
|
||||
|
||||
await refreshConfig();
|
||||
swrMutate("config/raw_paths");
|
||||
setPendingData(null);
|
||||
onSave?.();
|
||||
} catch (error) {
|
||||
@@ -1280,9 +1263,6 @@ export function ConfigSection({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("button.modified", {
|
||||
@@ -1354,9 +1334,6 @@ export function ConfigSection({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuChevronDown } from "react-icons/lu";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
CameraOverrideEntry,
|
||||
FieldDelta,
|
||||
useCamerasOverridingSection,
|
||||
} from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
import { getEffectiveHiddenFields } from "@/utils/configUtil";
|
||||
|
||||
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||
detect: "cameraDetect",
|
||||
ffmpeg: "cameraFfmpeg",
|
||||
record: "cameraRecording",
|
||||
snapshots: "cameraSnapshots",
|
||||
motion: "cameraMotion",
|
||||
objects: "cameraObjects",
|
||||
review: "cameraReview",
|
||||
audio: "cameraAudioEvents",
|
||||
audio_transcription: "cameraAudioTranscription",
|
||||
notifications: "cameraNotifications",
|
||||
live: "cameraLivePlayback",
|
||||
birdseye: "cameraBirdseye",
|
||||
face_recognition: "cameraFaceRecognition",
|
||||
lpr: "cameraLpr",
|
||||
timestamp_style: "cameraTimestampStyle",
|
||||
};
|
||||
|
||||
const MAX_FIELDS_PER_CAMERA = 5;
|
||||
|
||||
/**
|
||||
* Sections where the cross-camera override badge should be suppressed.
|
||||
* Includes enrichment sections that aren't meaningfully per-camera
|
||||
* (face recognition and LPR are intentionally omitted so the badge does show
|
||||
* there) and every System sub-page (detector hardware, database, networking,
|
||||
* etc.) which configures Frigate as a whole, not per-camera state.
|
||||
*/
|
||||
const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
|
||||
// Enrichments (face_recognition and lpr remain enabled)
|
||||
"semantic_search",
|
||||
"genai",
|
||||
"classification",
|
||||
"audio_transcription",
|
||||
// System
|
||||
"go2rtc_streams",
|
||||
"database",
|
||||
"mqtt",
|
||||
"tls",
|
||||
"auth",
|
||||
"networking",
|
||||
"proxy",
|
||||
"ui",
|
||||
"logger",
|
||||
"environment_vars",
|
||||
"telemetry",
|
||||
"birdseye",
|
||||
"detectors",
|
||||
"model",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Match a delta path against a hidden-field pattern. Supports literal prefixes
|
||||
* (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
|
||||
* matching exactly one path segment (e.g. "filters.*.mask").
|
||||
*/
|
||||
function pathMatchesHiddenPattern(path: string, pattern: string): boolean {
|
||||
if (!pattern) return false;
|
||||
if (!pattern.includes("*")) {
|
||||
return path === pattern || path.startsWith(`${pattern}.`);
|
||||
}
|
||||
const patternSegments = pattern.split(".");
|
||||
const pathSegments = path.split(".");
|
||||
if (pathSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i += 1) {
|
||||
if (patternSegments[i] === "*") continue;
|
||||
if (patternSegments[i] !== pathSegments[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
type CameraEntryProps = {
|
||||
sectionPath: string;
|
||||
entry: CameraOverrideEntry;
|
||||
cameraPage?: string;
|
||||
};
|
||||
|
||||
type SourceGroup = {
|
||||
/** undefined → camera-level; string → profile name */
|
||||
profileName: string | undefined;
|
||||
deltas: FieldDelta[];
|
||||
};
|
||||
|
||||
function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] {
|
||||
const cameraDeltas: FieldDelta[] = [];
|
||||
const byProfile = new Map<string, FieldDelta[]>();
|
||||
for (const delta of deltas) {
|
||||
if (delta.profileName) {
|
||||
const arr = byProfile.get(delta.profileName) ?? [];
|
||||
arr.push(delta);
|
||||
byProfile.set(delta.profileName, arr);
|
||||
} else {
|
||||
cameraDeltas.push(delta);
|
||||
}
|
||||
}
|
||||
const groups: SourceGroup[] = [];
|
||||
if (cameraDeltas.length > 0) {
|
||||
groups.push({ profileName: undefined, deltas: cameraDeltas });
|
||||
}
|
||||
for (const [profileName, group] of byProfile) {
|
||||
groups.push({ profileName, deltas: group });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||
const { t, i18n } = useTranslation([
|
||||
"config/global",
|
||||
"views/settings",
|
||||
"objects",
|
||||
]);
|
||||
const friendlyName = useCameraFriendlyName(entry.camera);
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
const profileFriendlyNames = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
profilesData?.profiles?.forEach((p) => map.set(p.name, p.friendly_name));
|
||||
return map;
|
||||
}, [profilesData]);
|
||||
|
||||
const fieldLabel = (fieldPath: string) => {
|
||||
if (!fieldPath) {
|
||||
const sectionKey = `${sectionPath}.label`;
|
||||
return i18n.exists(sectionKey, { ns: "config/global" })
|
||||
? t(sectionKey, { ns: "config/global" })
|
||||
: humanizeKey(sectionPath);
|
||||
}
|
||||
|
||||
const segments = fieldPath.split(".");
|
||||
|
||||
// Most specific: try the full nested path
|
||||
const fullKey = `${sectionPath}.${fieldPath}.label`;
|
||||
if (i18n.exists(fullKey, { ns: "config/global" })) {
|
||||
return t(fullKey, { ns: "config/global" });
|
||||
}
|
||||
|
||||
// Try dropping each intermediate segment in turn — those are typically
|
||||
// user-defined dict keys (object class names, zone names, etc.) that
|
||||
// don't have their own label entries. Prepend the dropped segment as
|
||||
// context to disambiguate (e.g. "Person · Minimum object area").
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
|
||||
".",
|
||||
);
|
||||
if (!reduced) continue;
|
||||
const reducedKey = `${sectionPath}.${reduced}.label`;
|
||||
if (i18n.exists(reducedKey, { ns: "config/global" })) {
|
||||
const resolvedLabel = t(reducedKey, { ns: "config/global" });
|
||||
const dropped = segments[i];
|
||||
// Object class names ("person", "car", "fox") have translations in
|
||||
// the `objects` namespace; fall back to humanizing the raw key for
|
||||
// anything that isn't a known label.
|
||||
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
|
||||
? t(dropped, { ns: "objects" })
|
||||
: humanizeKey(dropped);
|
||||
return `${droppedLabel} · ${resolvedLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: humanize the leaf segment
|
||||
return humanizeKey(segments[segments.length - 1]);
|
||||
};
|
||||
|
||||
const formatDeltas = (deltas: FieldDelta[]) => {
|
||||
const visibleLabels = deltas
|
||||
.slice(0, MAX_FIELDS_PER_CAMERA)
|
||||
.map((delta) => fieldLabel(delta.fieldPath));
|
||||
const hiddenCount = deltas.length - visibleLabels.length;
|
||||
const labelsForList =
|
||||
hiddenCount > 0
|
||||
? [
|
||||
...visibleLabels,
|
||||
t("button.overriddenInCameras.othersField", {
|
||||
ns: "views/settings",
|
||||
count: hiddenCount,
|
||||
}),
|
||||
]
|
||||
: visibleLabels;
|
||||
return formatList(labelsForList);
|
||||
};
|
||||
|
||||
const groups = groupDeltasBySource(entry.fieldDeltas);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{cameraPage ? (
|
||||
<Link
|
||||
to={`/settings?page=${cameraPage}&camera=${encodeURIComponent(entry.camera)}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{friendlyName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">{friendlyName}</span>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<span
|
||||
key={group.profileName ?? "__camera__"}
|
||||
className="ml-2 text-muted-foreground"
|
||||
>
|
||||
{group.profileName
|
||||
? t("button.overriddenInCameras.profilePrefix", {
|
||||
ns: "views/settings",
|
||||
profile:
|
||||
profileFriendlyNames.get(group.profileName) ??
|
||||
group.profileName,
|
||||
fields: formatDeltas(group.deltas),
|
||||
})
|
||||
: formatDeltas(group.deltas)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CameraOverridesBadge({ sectionPath, className }: Props) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const rawEntries = useCamerasOverridingSection(config, sectionPath);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const hiddenFields = getEffectiveHiddenFields(
|
||||
sectionPath,
|
||||
"global",
|
||||
config,
|
||||
);
|
||||
if (hiddenFields.length === 0) return rawEntries;
|
||||
return rawEntries
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
fieldDeltas: entry.fieldDeltas.filter(
|
||||
(delta) =>
|
||||
!hiddenFields.some((pattern) =>
|
||||
pathMatchesHiddenPattern(delta.fieldPath, pattern),
|
||||
),
|
||||
),
|
||||
}))
|
||||
.filter((entry) => entry.fieldDeltas.length > 0);
|
||||
}, [rawEntries, sectionPath, config]);
|
||||
|
||||
if (SECTIONS_WITHOUT_OVERRIDE_BADGE.has(sectionPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cameraPage = CAMERA_PAGE_BY_SECTION[sectionPath];
|
||||
const count = entries.length;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`cursor-pointer border-2 border-selected text-xs text-primary-variant ${className ?? ""}`}
|
||||
aria-label={t("button.overriddenInCameras.tooltip", {
|
||||
ns: "views/settings",
|
||||
count: count,
|
||||
})}
|
||||
>
|
||||
<span>
|
||||
{t("button.overriddenInCameras.label", {
|
||||
ns: "views/settings",
|
||||
count: count,
|
||||
})}
|
||||
</span>
|
||||
<LuChevronDown className="ml-1 size-3" />
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 max-w-[90vw] pr-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="pr-4 text-xs text-primary-variant">
|
||||
{t("button.overriddenInCameras.heading", {
|
||||
ns: "views/settings",
|
||||
count: count,
|
||||
})}
|
||||
</div>
|
||||
<div className="scrollbar-container flex max-h-[40dvh] flex-col gap-2 overflow-y-auto pr-4">
|
||||
{entries.map((entry) => (
|
||||
<CameraEntry
|
||||
key={entry.camera}
|
||||
sectionPath={sectionPath}
|
||||
entry={entry}
|
||||
cameraPage={cameraPage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { JsonObject, JsonValue } from "@/types/configForm";
|
||||
* Sections that require special handling at the global level.
|
||||
* Add new section paths here as needed.
|
||||
*/
|
||||
const SPECIAL_CASE_SECTIONS = ["motion", "detectors", "genai"] as const;
|
||||
const SPECIAL_CASE_SECTIONS = ["motion", "detectors"] as const;
|
||||
|
||||
/**
|
||||
* Check if a section requires special case handling.
|
||||
@@ -53,29 +53,6 @@ export function modifySchemaForSection(
|
||||
return schemaWithoutDefault;
|
||||
}
|
||||
|
||||
if (sectionPath === "genai") {
|
||||
const additional = schema.additionalProperties;
|
||||
if (
|
||||
additional &&
|
||||
typeof additional === "object" &&
|
||||
!Array.isArray(additional)
|
||||
) {
|
||||
const props = (additional as RJSFSchema).properties;
|
||||
if (props && typeof props.provider === "object") {
|
||||
return {
|
||||
...schema,
|
||||
additionalProperties: {
|
||||
...additional,
|
||||
properties: {
|
||||
...props,
|
||||
provider: { ...(props.provider as object), default: "openai" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -128,51 +105,6 @@ export function getEffectiveDefaultsForSection(
|
||||
return schemaDefaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add default filter entries for any label in `objects.track` that isn't
|
||||
* already in `objects.filters`, so each tracked label gets a collapsible.
|
||||
* The backend only auto-populates filters at config init, not after profile
|
||||
* merges.
|
||||
*/
|
||||
export function synthesizeMissingObjectFilters(
|
||||
sectionPath: string,
|
||||
data: unknown,
|
||||
sectionSchema: RJSFSchema | undefined,
|
||||
): unknown {
|
||||
if (sectionPath !== "objects") return data;
|
||||
if (!isJsonObject(data)) return data;
|
||||
|
||||
const trackValue = (data as JsonObject).track;
|
||||
if (!Array.isArray(trackValue) || trackValue.length === 0) return data;
|
||||
|
||||
const properties = (sectionSchema as { properties?: Record<string, unknown> })
|
||||
?.properties;
|
||||
const filtersSchema = isJsonObject(properties)
|
||||
? (properties.filters as { additionalProperties?: unknown } | undefined)
|
||||
: undefined;
|
||||
const filterEntrySchema = isJsonObject(filtersSchema?.additionalProperties)
|
||||
? (filtersSchema.additionalProperties as RJSFSchema)
|
||||
: undefined;
|
||||
|
||||
const existingFilters = isJsonObject((data as JsonObject).filters)
|
||||
? ((data as JsonObject).filters as JsonObject)
|
||||
: {};
|
||||
|
||||
const newFilters: JsonObject = { ...existingFilters };
|
||||
let added = false;
|
||||
for (const label of trackValue) {
|
||||
if (typeof label !== "string") continue;
|
||||
if (Object.prototype.hasOwnProperty.call(newFilters, label)) continue;
|
||||
newFilters[label] = (
|
||||
filterEntrySchema ? applySchemaDefaults(filterEntrySchema, {}) : {}
|
||||
) as JsonValue;
|
||||
added = true;
|
||||
}
|
||||
|
||||
if (!added) return data;
|
||||
return { ...(data as JsonObject), filters: newFilters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize overrides payloads for section-specific quirks.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { SwitchesWidget } from "./SwitchesWidget";
|
||||
import { FormContext } from "./SwitchesWidget";
|
||||
import i18n, { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { JsonObject } from "@/types/configForm";
|
||||
|
||||
@@ -76,12 +76,7 @@ function getObjectLabels(context: FormContext): string[] {
|
||||
...sourceLabels,
|
||||
...formDataLabels,
|
||||
]);
|
||||
return [...combinedLabels].sort((a, b) =>
|
||||
getObjectLabelDisplayName(a).localeCompare(
|
||||
getObjectLabelDisplayName(b),
|
||||
i18n.language,
|
||||
),
|
||||
);
|
||||
return [...combinedLabels].sort();
|
||||
}
|
||||
|
||||
function getObjectLabelDisplayName(label: string): string {
|
||||
@@ -99,7 +94,6 @@ export function ObjectLabelSwitchesWidget(props: WidgetProps) {
|
||||
i18nKey: "objectLabels",
|
||||
listClassName:
|
||||
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
enableSearch: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -93,14 +93,6 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
useSWR<ProfilesApiResponse>("profiles");
|
||||
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
|
||||
|
||||
const hasChatAgent = useMemo(
|
||||
() =>
|
||||
Object.values(config?.genai ?? {}).some((agent) =>
|
||||
agent?.roles?.includes("chat"),
|
||||
),
|
||||
[config?.genai],
|
||||
);
|
||||
|
||||
// languages
|
||||
|
||||
const languages = useMemo(() => {
|
||||
@@ -519,7 +511,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<span>{t("menu.classification")}</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
{hasChatAgent && (
|
||||
{config?.genai?.model !== "none" && (
|
||||
<Link to="/chat">
|
||||
<MenuItem
|
||||
className="flex w-full items-center p-2 text-sm"
|
||||
|
||||
@@ -98,7 +98,10 @@ export default function SearchResultActions({
|
||||
end_time: event.end_time,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
navigate("/replay");
|
||||
}
|
||||
})
|
||||
|
||||
@@ -209,7 +209,10 @@ export default function DebugReplayDialog({
|
||||
end_time: range.before,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
navigate("/replay");
|
||||
|
||||
@@ -262,7 +262,10 @@ export default function MobileReviewSettingsDrawer({
|
||||
end_time: debugReplayRange.before,
|
||||
});
|
||||
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
setDebugReplayMode("none");
|
||||
setDebugReplayRange(undefined);
|
||||
setDrawerMode("none");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user