mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-20 18:59:01 +03:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a4d3a5eb1 | ||
|
|
a1a9fd82a7 | ||
|
|
733c50c6e8 | ||
|
|
7f3118f1ef | ||
|
|
95e8603055 | ||
|
|
02ef484972 | ||
|
|
4ff7ab96dc | ||
|
|
d0f44de6bc | ||
|
|
5211590866 | ||
|
|
704ee9667c | ||
|
|
76a1230885 | ||
|
|
52a3301726 | ||
|
|
f448b259a2 | ||
|
|
ef9d7e07b7 | ||
|
|
814c497bef | ||
|
|
5bc15d4aa9 | ||
|
|
7ad233ef15 | ||
|
|
882b3a8ffd | ||
|
|
b6fd86a066 | ||
|
|
147cd5cc2b | ||
|
|
6a2b914b10 | ||
|
|
45213d0420 | ||
|
|
ba4a6a53d7 | ||
|
|
e90079ab2f | ||
|
|
edcf0b0d2c | ||
|
|
578d6391fb | ||
|
|
c9c9c1793d | ||
|
|
a3b10c308b | ||
|
|
540290bba7 | ||
|
|
b02d928056 | ||
|
|
057351e492 | ||
|
|
c7af51376b | ||
|
|
74cfa61952 | ||
|
|
81899ddf57 | ||
|
|
33e2b23f2e | ||
|
|
b5193c9978 | ||
|
|
93443773f4 | ||
|
|
57eb7db5c9 | ||
|
|
eb73277322 | ||
|
|
d3853688ed | ||
|
|
5fd8fc881a | ||
|
|
7000712454 | ||
|
|
72068b6823 | ||
|
|
04233c0574 | ||
|
|
1584400916 | ||
|
|
0d1189f26a | ||
|
|
29eff13a4c | ||
|
|
1162c01b3e | ||
|
|
27b7ef0a7a | ||
|
|
2cfb530dbf | ||
|
|
81b0d94793 | ||
|
|
67837f61d0 | ||
|
|
58c93c2e9e | ||
|
|
6b71feffab | ||
|
|
1c26bc289e | ||
|
|
0371b60c71 | ||
|
|
01392e03ac |
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR description against template
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const maintainers = ['blakeblackshear', 'NickM-27', 'hawkeye217', 'dependabot[bot]', 'weblate'];
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
run: npm run e2e
|
||||
working-directory: ./web
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
close-issue-message: ""
|
||||
days-before-stale: 30
|
||||
days-before-close: 3
|
||||
exempt-draft-pr: true
|
||||
exempt-draft-pr: false
|
||||
exempt-issue-labels: "planned,security"
|
||||
exempt-pr-labels: "planned,security,dependencies"
|
||||
operations-per-run: 120
|
||||
|
||||
@@ -22,3 +22,8 @@ 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
|
||||
|
||||
+7
-2
@@ -10,11 +10,14 @@ If you've found a bug and want to fix it, go for it. Link to the relevant issue
|
||||
|
||||
### New features
|
||||
|
||||
Every new feature adds scope that the maintainers must test, maintain, and support long-term. Before writing code for a new feature:
|
||||
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:
|
||||
|
||||
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
|
||||
|
||||
@@ -39,6 +42,8 @@ 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/ubuntu/jammy/amdgpu-install_7.2.70200-1_all.deb && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2.3/ubuntu/jammy/amdgpu-install_7.2.3.70203-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
@@ -78,6 +78,10 @@ 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.0/onnxruntime_migraphx-1.23.1-cp311-cp311-linux_x86_64.whl
|
||||
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
|
||||
@@ -1,5 +1,5 @@
|
||||
variable "ROCM" {
|
||||
default = "7.2.0"
|
||||
default = "7.2.3"
|
||||
}
|
||||
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/#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
|
||||
When running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/index.md#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
|
||||
|
||||
When running a default COCO model or another model that does not include `face` as a detectable label, face detection will run via CV2 using a lightweight DNN model that runs on the CPU. In this case, you should _not_ define `face` in your list of objects to track.
|
||||
|
||||
|
||||
@@ -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 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).
|
||||
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).
|
||||
|
||||
#### Configuration
|
||||
|
||||
@@ -210,7 +210,8 @@ Ollama also supports [cloud models](https://ollama.com/cloud), where your local
|
||||
|
||||
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`)
|
||||
- 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 **Model** to the cloud model name
|
||||
|
||||
</TabItem>
|
||||
@@ -223,6 +224,16 @@ 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,90 +136,32 @@ ffmpeg:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
### Configuring Intel GPU Stats in Docker
|
||||
### Configuring Intel GPU Stats
|
||||
|
||||
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:
|
||||
Frigate reads Intel GPU utilization directly from the kernel's per-client DRM usage counters exposed at `/proc/<pid>/fdinfo/<fd>`. This requires:
|
||||
|
||||
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.)
|
||||
- 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`.
|
||||
|
||||
#### Run as privileged
|
||||
No `intel_gpu_top` binary, `CAP_PERFMON`, privileged mode, or `perf_event_paranoid` tuning is required.
|
||||
|
||||
This method works, but it gives more permissions to the container than are actually needed.
|
||||
#### Stats for SR-IOV or specific devices
|
||||
|
||||
##### 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:
|
||||
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:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
stats:
|
||||
intel_gpu_device: "sriov"
|
||||
intel_gpu_device: "0000:00:02.0"
|
||||
```
|
||||
|
||||
For other virtualized GPUs, try specifying the direct path to the device instead:
|
||||
|
||||
```yaml
|
||||
telemetry:
|
||||
stats:
|
||||
intel_gpu_device: "drm:/dev/dri/card0"
|
||||
intel_gpu_device: "/dev/dri/card1"
|
||||
```
|
||||
|
||||
If you are passing in a device path, make sure you've passed the device through to the container.
|
||||
When passing a device path, make sure the device is also passed 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 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.
|
||||
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.
|
||||
|
||||
## 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](#d-fine) | ❌ | ❌ | |
|
||||
| [D-FINE / DEIMv2](#d-fine--deimv2) | ❌ | ❌ | |
|
||||
|
||||
#### SSDLite MobileNet v2
|
||||
|
||||
@@ -710,13 +710,13 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
#### D-FINE
|
||||
#### D-FINE / DEIMv2
|
||||
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
|
||||
|
||||
:::warning
|
||||
|
||||
Currently D-FINE models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model
|
||||
Currently D-FINE / DEIMv2 models only run on OpenVINO in CPU mode, GPUs currently fail to compile the model
|
||||
|
||||
:::
|
||||
|
||||
@@ -766,6 +766,31 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>DEIMv2 Setup & Config</summary>
|
||||
|
||||
After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
ov:
|
||||
type: openvino
|
||||
device: CPU
|
||||
|
||||
model:
|
||||
model_type: dfine
|
||||
width: 640
|
||||
height: 640
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/model_cache/deimv2_hgnetv2_n.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
```
|
||||
|
||||
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
||||
|
||||
</details>
|
||||
|
||||
## Apple Silicon detector
|
||||
|
||||
The NPU in Apple Silicon can't be accessed from within a container, so the [Apple Silicon detector client](https://github.com/frigate-nvr/apple-silicon-detector) must first be setup. It is recommended to use the Frigate docker image with `-standard-arm64` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-standard-arm64`.
|
||||
@@ -947,7 +972,7 @@ The AMD GPU kernel is known problematic especially when converting models to mxr
|
||||
|
||||
See [ONNX supported models](#supported-models) for supported models, there are some caveats:
|
||||
|
||||
- D-FINE models are not supported
|
||||
- D-FINE / DEIMv2 models are not supported
|
||||
- YOLO-NAS models are known to not run well on integrated GPUs
|
||||
|
||||
## ONNX
|
||||
@@ -997,13 +1022,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](#d-fine) | ⚠️ | ❌ | 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 / DEIMv2](#d-fine--deimv2-1) | ⚠️ | ❌ | Not supported by CUDA Graphs |
|
||||
|
||||
There is no default model provided, the following formats are supported:
|
||||
|
||||
@@ -1215,9 +1240,9 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
#### D-FINE
|
||||
#### D-FINE / DEIMv2
|
||||
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) is a DETR based model. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
|
||||
[D-FINE](https://github.com/Peterande/D-FINE) and [DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) are DETR based models that share the same ONNX input/output format. The ONNX exported models are supported, but not included by default. See the models section for downloading [D-FINE](#downloading-d-fine-model) or [DEIMv2](#downloading-deimv2-model) for use in Frigate.
|
||||
|
||||
<details>
|
||||
<summary>D-FINE Setup & Config</summary>
|
||||
@@ -1262,6 +1287,28 @@ model:
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>DEIMv2 Setup & Config</summary>
|
||||
|
||||
After placing the downloaded onnx model in your `config/model_cache` folder, you can use the following configuration:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
onnx:
|
||||
type: onnx
|
||||
|
||||
model:
|
||||
model_type: dfine
|
||||
width: 640
|
||||
height: 640
|
||||
input_tensor: nchw
|
||||
input_dtype: float
|
||||
path: /config/model_cache/deimv2_hgnetv2_n.onnx
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
|
||||
|
||||
## CPU Detector (not recommended)
|
||||
@@ -1405,7 +1452,7 @@ MemryX `.dfp` models are automatically downloaded at runtime, if enabled, to the
|
||||
|
||||
#### YOLO-NAS
|
||||
|
||||
The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage).
|
||||
The [YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) model included in this detector is downloaded from the [Models Section](#downloading-yolo-nas-model) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).
|
||||
|
||||
**Note:** The default model for the MemryX detector is YOLO-NAS 320x320.
|
||||
|
||||
@@ -1459,7 +1506,7 @@ model:
|
||||
|
||||
#### YOLOv9
|
||||
|
||||
The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/tools/neural_compiler.html#usage).
|
||||
The YOLOv9s model included in this detector is downloaded from [the original GitHub](https://github.com/WongKinYiu/yolov9) like in the [Models Section](#yolov9-1) and compiled to DFP with [mx_nc](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage).
|
||||
|
||||
##### Configuration
|
||||
|
||||
@@ -1601,19 +1648,39 @@ model:
|
||||
|
||||
#### Using a Custom Model
|
||||
|
||||
To use your own model:
|
||||
To use your own custom model, first compile it into a [.dfp](https://developer.memryx.com/2p1/specs/files.html#dataflow-program) file, which is the format used by MemryX.
|
||||
|
||||
1. Package your compiled model into a `.zip` file.
|
||||
#### Compile the Model
|
||||
|
||||
2. The `.zip` must contain the compiled `.dfp` file.
|
||||
Custom models must be compiled using **MemryX SDK 2.1**.
|
||||
|
||||
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`.
|
||||
Before compiling your model, install the MemryX Neural Compiler tools from the
|
||||
[Install Tools](https://developer.memryx.com/2p1/get_started/install_tools.html) page on the **host**.
|
||||
|
||||
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config.
|
||||
> **Note:** It is recommended to compile the model on the host machine, or on another separate machine, rather than inside the Frigate Docker container. Installing the compiler inside Docker may conflict with container packages. It is recommended to create a Python virtual environment and install the compiler there.
|
||||
|
||||
5. Update the `labelmap_path` to match your custom model's labels.
|
||||
Once the SDK 2.1 environment is set up, follow the
|
||||
[MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) documentation to compile your model.
|
||||
|
||||
For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/tutorials/tutorials.html).
|
||||
Example:
|
||||
|
||||
```bash
|
||||
mx_nc -m yolonas.onnx -c 4 --autocrop -v --dfp_fname yolonas.dfp
|
||||
```
|
||||
|
||||
For detailed instructions on compiling models, refer to the [MemryX Compiler](https://developer.memryx.com/2p1/tools/neural_compiler.html#usage) docs and [Tutorials](https://developer.memryx.com/2p1/tutorials/tutorials.html).
|
||||
|
||||
#### Package the Compiled Model
|
||||
|
||||
1. Package your compiled model into a `.zip` file.
|
||||
|
||||
2. The `.zip` file must contain the compiled `.dfp` file.
|
||||
|
||||
3. Depending on the model, the compiler may also generate a cropped post-processing network. If present, it will be named with the suffix `_post.onnx`.
|
||||
|
||||
4. Bind-mount the `.zip` file into the container and specify its path using `model.path` in your config.
|
||||
|
||||
5. Update `labelmap_path` to match your custom model's labels.
|
||||
|
||||
```yaml
|
||||
# The detector automatically selects the default model if nothing is provided in the config.
|
||||
@@ -2274,6 +2341,49 @@ COPY --from=build /dfine/output/dfine_${MODEL_SIZE}_obj2coco.onnx /dfine-${MODEL
|
||||
EOF
|
||||
```
|
||||
|
||||
### Downloading DEIMv2 Model
|
||||
|
||||
[DEIMv2](https://github.com/Intellindust-AI-Lab/DEIMv2) can be exported as ONNX by running the command below. Pretrained weights are available on Hugging Face for two backbone families:
|
||||
|
||||
- **HGNetv2** (smaller/faster): `atto`, `femto`, `pico`, `n`
|
||||
- **DINOv3** (larger/more accurate): `s`, `m`, `l`, `x`
|
||||
|
||||
Set `BACKBONE` and `MODEL_SIZE` in the first line to match your desired variant. Hugging Face model names use uppercase (e.g. `HGNetv2_N`, `DINOv3_S`), while config files use lowercase (e.g. `hgnetv2_n`, `dinov3_s`).
|
||||
|
||||
```sh
|
||||
docker build . --rm --build-arg BACKBONE=hgnetv2 --build-arg MODEL_SIZE=n --output . -f- <<'EOF'
|
||||
FROM python:3.11-slim AS build
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y git libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
|
||||
WORKDIR /deimv2
|
||||
RUN git clone https://github.com/Intellindust-AI-Lab/DEIMv2.git .
|
||||
# Install CPU-only PyTorch first to avoid pulling CUDA variant
|
||||
RUN uv pip install --no-cache --system torch torchvision --index-url https://download.pytorch.org/whl/cpu
|
||||
RUN uv pip install --no-cache --system -r requirements.txt
|
||||
RUN uv pip install --no-cache --system onnx safetensors huggingface_hub
|
||||
RUN mkdir -p output
|
||||
ARG BACKBONE
|
||||
ARG MODEL_SIZE
|
||||
# Download from Hugging Face and convert safetensors to pth
|
||||
RUN python3 -c "\
|
||||
from huggingface_hub import hf_hub_download; \
|
||||
from safetensors.torch import load_file; \
|
||||
import torch; \
|
||||
backbone = '${BACKBONE}'.replace('hgnetv2','HGNetv2').replace('dinov3','DINOv3'); \
|
||||
size = '${MODEL_SIZE}'.upper(); \
|
||||
st = load_file(hf_hub_download('Intellindust/DEIMv2_' + backbone + '_' + size + '_COCO', 'model.safetensors')); \
|
||||
torch.save({'model': st}, 'output/deimv2.pth')"
|
||||
RUN sed -i "s/data = torch.rand(2/data = torch.rand(1/" tools/deployment/export_onnx.py
|
||||
# HuggingFace safetensors omits frozen constants that the model constructor initializes
|
||||
RUN sed -i "s/cfg.model.load_state_dict(state)/cfg.model.load_state_dict(state, strict=False)/" tools/deployment/export_onnx.py
|
||||
RUN python3 tools/deployment/export_onnx.py -c configs/deimv2/deimv2_${BACKBONE}_${MODEL_SIZE}_coco.yml -r output/deimv2.pth
|
||||
FROM scratch
|
||||
ARG BACKBONE
|
||||
ARG MODEL_SIZE
|
||||
COPY --from=build /deimv2/output/deimv2.onnx /deimv2_${BACKBONE}_${MODEL_SIZE}.onnx
|
||||
EOF
|
||||
```
|
||||
|
||||
### Downloading RF-DETR Model
|
||||
|
||||
RF-DETR can be exported as ONNX by running the command below. You can copy and paste the whole thing to your terminal and execute, altering `MODEL_SIZE=Nano` in the first line to `Nano`, `Small`, or `Medium` size.
|
||||
|
||||
@@ -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?
|
||||
|
||||
As of Frigate 0.12 if there is less than an hour left of storage, the oldest 2 hours of recordings will be deleted.
|
||||
If there is less than an hour left of storage, the oldest hour of recordings will be deleted and a message will be printed in the Frigate logs. This emergency cleanup deletes the oldest recordings first regardless of retention settings to reclaim space as quickly as possible.
|
||||
|
||||
## Configuring Recording Retention
|
||||
|
||||
|
||||
@@ -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. 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 and other applications. An example is below:
|
||||
|
||||
:::warning
|
||||
|
||||
@@ -244,16 +244,11 @@ The `exec:`, `echo:`, and `expr:` sources are disabled by default for security.
|
||||
|
||||
:::
|
||||
|
||||
:::warning
|
||||
|
||||
The `exec:`, `echo:`, and `expr:` sources are disabled by default for security. You must set `GO2RTC_ALLOW_ARBITRARY_EXEC=true` to use them. See [Security: Restricted Stream Sources](#security-restricted-stream-sources) for more information.
|
||||
|
||||
:::
|
||||
|
||||
NOTE: The output will need to be passed with two curly braces `{{output}}`
|
||||
NOTE: RTSP output will need to be passed with two curly braces `{{output}}`, whereas pipe output must be passed without curly braces.
|
||||
|
||||
```yaml
|
||||
go2rtc:
|
||||
streams:
|
||||
stream1: exec:ffmpeg -hide_banner -re -stream_loop -1 -i /media/BigBuckBunny.mp4 -c copy -rtsp_transport tcp -f rtsp {{output}}
|
||||
stream2: exec:rpicam-vid -t 0 --libav-format h264 -o -
|
||||
```
|
||||
|
||||
@@ -223,10 +223,11 @@ 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 |
|
||||
| --------- | --------------------------- | ------------------------- |
|
||||
| AMD 780M | t-320: ~ 14 ms s-320: 20 ms | 320: ~ 25 ms 640: ~ 50 ms |
|
||||
| AMD 8700G | | 320: ~ 20 ms 640: ~ 40 ms |
|
||||
| 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 |
|
||||
|
||||
## Community Supported Detectors
|
||||
|
||||
|
||||
@@ -4,12 +4,15 @@ 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#configuring-frigate) to configure Frigate.
|
||||
If you already have Frigate installed as a Home Assistant App, check out the [getting started guide](../guides/getting_started.md#configuring-frigate) to configure Frigate.
|
||||
|
||||
:::
|
||||
|
||||
@@ -286,7 +289,7 @@ The MemryX MX3 Accelerator is available in the M.2 2280 form factor (like an NVM
|
||||
|
||||
#### Installation
|
||||
|
||||
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/get_started/hardware_setup.html).
|
||||
To get started with MX3 hardware setup for your system, refer to the [Hardware Setup Guide](https://developer.memryx.com/2p1/get_started/install_hardware.html).
|
||||
|
||||
Then follow these steps for installing the correct driver/runtime configuration:
|
||||
|
||||
@@ -295,6 +298,12 @@ Then follow these steps for installing the correct driver/runtime configuration:
|
||||
3. Run the script with `./user_installation.sh`
|
||||
4. **Restart your computer** to complete driver installation.
|
||||
|
||||
:::warning
|
||||
|
||||
For manual setup, use **MemryX SDK 2.1** only. Other SDK versions are not supported for this setup. See the [SDK 2.1 documentation](https://developer.memryx.com/2p1/index.html)
|
||||
|
||||
:::
|
||||
|
||||
#### Setup
|
||||
|
||||
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||
@@ -468,6 +477,16 @@ 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:
|
||||
@@ -501,6 +520,10 @@ 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 CPU detector.
|
||||
By default, Frigate will use a single OpenVINO detector running on the CPU.
|
||||
|
||||
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
+8
-1
@@ -14,9 +14,11 @@
|
||||
"@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",
|
||||
@@ -5747,6 +5749,11 @@
|
||||
"@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",
|
||||
@@ -12883,7 +12890,7 @@
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"resolved": "https://mirrors.tencent.com/npm/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+5
-2
@@ -3,9 +3,10 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:config": "node scripts/build-config.mjs",
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "npm run regen-docs && docusaurus start --host 0.0.0.0",
|
||||
"build": "npm run regen-docs && docusaurus build",
|
||||
"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",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
@@ -23,9 +24,11 @@
|
||||
"@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",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/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!");
|
||||
@@ -0,0 +1,108 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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
@@ -0,0 +1,12 @@
|
||||
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";
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./DockerComposeGenerator";
|
||||
@@ -0,0 +1,381 @@
|
||||
/* ===================================================================
|
||||
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
+13
-2
@@ -5997,7 +5997,10 @@ paths:
|
||||
tags:
|
||||
- App
|
||||
summary: Start debug replay
|
||||
description: Start a debug replay session from camera recordings.
|
||||
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.
|
||||
operationId: start_debug_replay_debug_replay_start_post
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -6006,12 +6009,16 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DebugReplayStartBody"
|
||||
responses:
|
||||
"200":
|
||||
"202":
|
||||
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:
|
||||
@@ -6272,10 +6279,14 @@ 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:
|
||||
|
||||
+44
-1
@@ -146,8 +146,13 @@ def config(request: Request):
|
||||
for name, detector in config_obj.detectors.items()
|
||||
}
|
||||
|
||||
# remove the mqtt password
|
||||
# remove environment_vars for non-admin users
|
||||
if request.headers.get("remote-role") != "admin":
|
||||
config.pop("environment_vars", None)
|
||||
|
||||
# remove mqtt credentials
|
||||
config["mqtt"].pop("password", None)
|
||||
config["mqtt"].pop("user", None)
|
||||
|
||||
# remove the proxy secret
|
||||
config["proxy"].pop("auth_secret", None)
|
||||
@@ -494,6 +499,40 @@ 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.
|
||||
|
||||
@@ -504,6 +543,7 @@ 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()}
|
||||
|
||||
@@ -610,6 +650,9 @@ 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,6 +812,11 @@ 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
|
||||
|
||||
+14
-1
@@ -37,6 +37,7 @@ 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,
|
||||
@@ -1301,6 +1302,7 @@ 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
|
||||
@@ -1311,6 +1313,10 @@ 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)})"
|
||||
@@ -1326,6 +1332,13 @@ 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}
|
||||
@@ -1337,7 +1350,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}"""
|
||||
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}"""
|
||||
|
||||
conversation.append(
|
||||
{
|
||||
|
||||
+44
-30
@@ -10,6 +10,7 @@ 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__)
|
||||
|
||||
@@ -29,10 +30,17 @@ class DebugReplayStartResponse(BaseModel):
|
||||
|
||||
success: bool
|
||||
replay_camera: str
|
||||
job_id: str
|
||||
|
||||
|
||||
class DebugReplayStatusResponse(BaseModel):
|
||||
"""Response for debug replay status."""
|
||||
"""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").
|
||||
"""
|
||||
|
||||
active: bool
|
||||
replay_camera: str | None = None
|
||||
@@ -51,15 +59,32 @@ 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.",
|
||||
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.",
|
||||
)
|
||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
"""Start a debug replay session."""
|
||||
"""Start a debug replay session asynchronously."""
|
||||
replay_manager = request.app.replay_manager
|
||||
|
||||
if replay_manager.active:
|
||||
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:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
@@ -67,38 +92,23 @@ 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("Invalid parameters for debug replay start request")
|
||||
logger.exception("Rejected debug replay start request")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Invalid debug replay request parameters",
|
||||
"message": "Invalid debug replay 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 DebugReplayStartResponse(
|
||||
success=True,
|
||||
replay_camera=replay_camera,
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"replay_camera": replay_manager.replay_camera_name,
|
||||
"job_id": job_id,
|
||||
},
|
||||
status_code=202,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,12 +128,16 @@ 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)
|
||||
frame = (
|
||||
frame_processor.get_current_frame(replay_camera)
|
||||
if frame_processor is not None
|
||||
else None
|
||||
)
|
||||
|
||||
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
|
||||
retry_interval = 10.0
|
||||
|
||||
if camera_config is not None:
|
||||
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
||||
|
||||
@@ -174,12 +174,10 @@ async def latest_frame(
|
||||
}
|
||||
quality_params = get_image_quality_params(extension.value, params.quality)
|
||||
|
||||
if camera_name in request.app.frigate_config.cameras:
|
||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
||||
if camera_config is not None:
|
||||
frame = frame_processor.get_current_frame(camera_name, draw_options)
|
||||
retry_interval = float(
|
||||
request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval
|
||||
or 10
|
||||
)
|
||||
retry_interval = float(camera_config.ffmpeg.retry_interval or 10)
|
||||
|
||||
is_offline = False
|
||||
if frame is None or datetime.now().timestamp() > (
|
||||
|
||||
@@ -429,7 +429,10 @@ class WebPushClient(Communicator):
|
||||
else:
|
||||
title = base_title
|
||||
|
||||
message = payload["after"]["data"]["metadata"]["shortSummary"]
|
||||
if payload["after"]["data"]["metadata"].get("shortSummary"):
|
||||
message = payload["after"]["data"]["metadata"]["shortSummary"]
|
||||
else:
|
||||
message = f"Detected on {camera_name}"
|
||||
else:
|
||||
zone_names = payload["after"]["data"]["zones"]
|
||||
formatted_zone_names = []
|
||||
|
||||
+99
-3
@@ -17,9 +17,90 @@ 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:
|
||||
@@ -49,6 +130,7 @@ 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:
|
||||
@@ -63,11 +145,25 @@ class WebSocketClient(Communicator):
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Publishing mqtt message from websockets at {json_message['topic']}."
|
||||
topic = json_message["topic"]
|
||||
|
||||
# Authorization check (skip when environ is None — direct internal connection)
|
||||
role_header = (
|
||||
self.environ.get("HTTP_REMOTE_ROLE") if self.environ else None
|
||||
)
|
||||
if self.environ is not None and not _check_ws_authorization(
|
||||
topic, role_header, self.role_separator
|
||||
):
|
||||
logger.warning(
|
||||
"Blocked unauthorized WebSocket message: topic=%s, role=%s",
|
||||
topic,
|
||||
role_header,
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(f"Publishing mqtt message from websockets at {topic}.")
|
||||
self.receiver(
|
||||
json_message["topic"],
|
||||
topic,
|
||||
json_message["payload"],
|
||||
)
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
# Options with global fallback
|
||||
audio: AudioConfig = Field(
|
||||
default_factory=AudioConfig,
|
||||
title="Audio events",
|
||||
title="Audio detection",
|
||||
description="Settings for audio-based event detection for this camera.",
|
||||
)
|
||||
audio_transcription: CameraAudioTranscriptionConfig = Field(
|
||||
|
||||
@@ -41,8 +41,7 @@ class GenAIConfig(FrigateBaseModel):
|
||||
title="Model",
|
||||
description="The model to use from the provider for generating descriptions or summaries.",
|
||||
)
|
||||
provider: GenAIProviderEnum | None = Field(
|
||||
default=None,
|
||||
provider: GenAIProviderEnum = Field(
|
||||
title="Provider",
|
||||
description="The GenAI provider to use (for example: ollama, gemini, openai).",
|
||||
)
|
||||
|
||||
@@ -121,7 +121,10 @@ 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,6 +26,11 @@ class EnrichmentsDeviceEnum(str, Enum):
|
||||
CPU = "CPU"
|
||||
|
||||
|
||||
class ModelSizeEnum(str, Enum):
|
||||
small = "small"
|
||||
large = "large"
|
||||
|
||||
|
||||
class TriggerType(str, Enum):
|
||||
THUMBNAIL = "thumbnail"
|
||||
DESCRIPTION = "description"
|
||||
@@ -53,13 +58,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: Optional[EnrichmentsDeviceEnum] = Field(
|
||||
device: 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: str = Field(
|
||||
default="small",
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
title="Model size",
|
||||
description="Model size to use for offline audio event transcription.",
|
||||
)
|
||||
@@ -189,8 +194,8 @@ class SemanticSearchConfig(FrigateBaseModel):
|
||||
return v
|
||||
return v
|
||||
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
title="Model size",
|
||||
description="Select model size; 'small' runs on CPU and 'large' typically requires GPU.",
|
||||
)
|
||||
@@ -253,8 +258,8 @@ class FaceRecognitionConfig(FrigateBaseModel):
|
||||
title="Enable face recognition",
|
||||
description="Enable or disable face recognition for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
title="Model size",
|
||||
description="Model size to use for face embeddings (small/large); larger may require GPU.",
|
||||
)
|
||||
@@ -335,8 +340,8 @@ class LicensePlateRecognitionConfig(FrigateBaseModel):
|
||||
title="Enable LPR",
|
||||
description="Enable or disable license plate recognition for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
model_size: str = Field(
|
||||
default="small",
|
||||
model_size: ModelSizeEnum = Field(
|
||||
default=ModelSizeEnum.small,
|
||||
title="Model size",
|
||||
description="Model size used for text detection/recognition. Most users should use 'small'.",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -80,17 +81,40 @@ 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()
|
||||
|
||||
@@ -453,7 +477,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
cameras: Dict[str, CameraConfig] = Field(title="Cameras", description="Cameras")
|
||||
audio: AudioConfig = Field(
|
||||
default_factory=AudioConfig,
|
||||
title="Audio events",
|
||||
title="Audio detection",
|
||||
description="Settings for audio-based event detection for all cameras; can be overridden per-camera.",
|
||||
)
|
||||
birdseye: BirdseyeConfig = Field(
|
||||
@@ -679,6 +703,9 @@ 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="SR-IOV device",
|
||||
description="Device identifier used when treating Intel GPUs as SR-IOV to fix GPU stats.",
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1073,10 +1073,6 @@ 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
|
||||
@@ -1092,9 +1088,6 @@ 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
|
||||
@@ -1360,8 +1353,8 @@ class LicensePlateProcessingMixin:
|
||||
)
|
||||
|
||||
# check that license plate is valid
|
||||
# double the value because we've doubled the size of the car
|
||||
if license_plate_area < self.config.cameras[camera].lpr.min_area * 2:
|
||||
# quadruple the value because we've doubled both dimensions of the car
|
||||
if license_plate_area < self.config.cameras[camera].lpr.min_area * 4:
|
||||
logger.debug(f"{camera}: License plate is less than min_area")
|
||||
return
|
||||
|
||||
@@ -1465,6 +1458,7 @@ 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
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
|
||||
|
||||
ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=160)]
|
||||
ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=200)]
|
||||
|
||||
|
||||
class ReviewMetadata(BaseModel):
|
||||
@@ -11,33 +11,22 @@ class ReviewMetadata(BaseModel):
|
||||
observations: list[ObservationItem] = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=15,
|
||||
description=(
|
||||
"Enumerate the significant observations across all frames, in "
|
||||
"chronological order, BEFORE composing the scene narrative. "
|
||||
"Include the very start of the activity — for example, a vehicle "
|
||||
"entering the frame or pulling into the driveway — even if it "
|
||||
"lasts only a few frames and the rest of the clip is dominated "
|
||||
"by a longer activity. Include each arrival, departure, motion "
|
||||
"event, object handled, and notable change in position or state. "
|
||||
"Each item is a single concrete fact written as a complete "
|
||||
"sentence. Do not summarize, interpret, or assign meaning here — "
|
||||
"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.",
|
||||
max_length=8,
|
||||
description="Enumerate the significant observations across all frames, in chronological order.",
|
||||
)
|
||||
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="Title for the activity.",
|
||||
)
|
||||
shortSummary: str = Field(
|
||||
min_length=70,
|
||||
max_length=120,
|
||||
description="A brief 2-sentence summary of the scene, suitable for notifications.",
|
||||
max_length=140,
|
||||
description="A brief summary for the activity.",
|
||||
)
|
||||
confidence: float = Field(
|
||||
ge=0.0,
|
||||
|
||||
@@ -229,9 +229,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
logger.debug(f"No person box available for {id}")
|
||||
return
|
||||
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
|
||||
# YuNet (cv2.FaceDetectorYN) is trained on BGR
|
||||
bgr = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420)
|
||||
left, top, right, bottom = person_box
|
||||
person = rgb[top:bottom, left:right]
|
||||
person = bgr[top:bottom, left:right]
|
||||
face_box = self.__detect_face(person, self.face_config.detection_threshold)
|
||||
|
||||
if not face_box:
|
||||
@@ -250,11 +251,6 @@ 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"):
|
||||
|
||||
+78
-182
@@ -1,9 +1,13 @@
|
||||
"""Debug replay camera management for replaying recordings with detection overlays."""
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess as sp
|
||||
import threading
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
@@ -21,7 +25,7 @@ from frigate.const import (
|
||||
REPLAY_DIR,
|
||||
THUMB_DIR,
|
||||
)
|
||||
from frigate.models import Recordings
|
||||
from frigate.jobs.debug_replay import cancel_debug_replay_job, wait_for_runner
|
||||
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
|
||||
from frigate.util.config import find_config_file
|
||||
|
||||
@@ -29,7 +33,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DebugReplayManager:
|
||||
"""Manages a single debug replay session."""
|
||||
"""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.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
@@ -41,144 +52,66 @@ class DebugReplayManager:
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
"""Whether a replay session is currently active."""
|
||||
"""True from mark_starting until clear_session."""
|
||||
return self.replay_camera_name is not None
|
||||
|
||||
def start(
|
||||
def mark_starting(
|
||||
self,
|
||||
source_camera: str,
|
||||
replay_camera_name: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> str:
|
||||
"""Start a debug replay session.
|
||||
) -> None:
|
||||
"""Synchronously claim the session before the job runner starts.
|
||||
|
||||
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
|
||||
Called inside the API handler so the status bar sees active=True
|
||||
immediately, before the worker thread does any ffmpeg work.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._start_locked(
|
||||
source_camera, start_ts, end_ts, frigate_config, config_publisher
|
||||
)
|
||||
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 _start_locked(
|
||||
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,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
replay_name: str,
|
||||
clip_path: str,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> str:
|
||||
if self.active:
|
||||
raise ValueError("A replay session is already active")
|
||||
) -> None:
|
||||
"""Build the in-memory replay camera config and publish the add event.
|
||||
|
||||
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
|
||||
Called by the job runner during the starting_camera phase.
|
||||
"""
|
||||
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:
|
||||
@@ -191,75 +124,48 @@ class DebugReplayManager:
|
||||
try:
|
||||
new_config = FrigateConfig.parse_object(config_data)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to validate replay camera config: {e}")
|
||||
|
||||
# Update the running config
|
||||
raise RuntimeError(f"Failed to validate replay camera config: {e}") from e
|
||||
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:
|
||||
"""Stop the active replay session and clean up all artifacts.
|
||||
"""Cancel any in-flight startup job and tear down the active session.
|
||||
|
||||
Args:
|
||||
frigate_config: Current Frigate configuration
|
||||
config_publisher: Publisher for camera config updates
|
||||
Safe to call when no session is active (no-op with a warning).
|
||||
"""
|
||||
cancel_debug_replay_job()
|
||||
wait_for_runner(timeout=2.0)
|
||||
|
||||
with self._lock:
|
||||
self._stop_locked(frigate_config, config_publisher)
|
||||
if not self.active:
|
||||
logger.warning("No active replay session to stop")
|
||||
return
|
||||
|
||||
def _stop_locked(
|
||||
self,
|
||||
frigate_config: FrigateConfig,
|
||||
config_publisher: CameraConfigUpdatePublisher,
|
||||
) -> None:
|
||||
if not self.active:
|
||||
logger.warning("No active replay session to stop")
|
||||
return
|
||||
replay_name = self.replay_camera_name
|
||||
|
||||
replay_name = self.replay_camera_name
|
||||
# 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],
|
||||
)
|
||||
|
||||
# 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
|
||||
if replay_name is not None:
|
||||
self._cleanup_db(replay_name)
|
||||
self._cleanup_files(replay_name)
|
||||
|
||||
# Defensive DB cleanup
|
||||
self._cleanup_db(replay_name)
|
||||
self._clear_locked()
|
||||
|
||||
# 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)
|
||||
logger.info("Debug replay stopped and cleaned up: %s", replay_name)
|
||||
|
||||
def _build_camera_config_dict(
|
||||
self,
|
||||
@@ -267,16 +173,7 @@ class DebugReplayManager:
|
||||
replay_name: str,
|
||||
clip_path: str,
|
||||
) -> dict:
|
||||
"""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
|
||||
"""
|
||||
"""Build a camera config dictionary for the replay camera."""
|
||||
# Extract detect config (exclude computed fields)
|
||||
detect_dict = source_config.detect.model_dump(
|
||||
exclude={"min_initialized", "max_disappeared", "enabled_in_config"}
|
||||
@@ -311,7 +208,6 @@ 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,7 +132,6 @@ class ONNXModelRunner(BaseModelRunner):
|
||||
return model_type in [
|
||||
EnrichmentModelTypeEnum.paddleocr.value,
|
||||
EnrichmentModelTypeEnum.jina_v2.value,
|
||||
EnrichmentModelTypeEnum.arcface.value,
|
||||
ModelTypeEnum.rfdetr.value,
|
||||
ModelTypeEnum.dfine.value,
|
||||
]
|
||||
|
||||
@@ -52,6 +52,12 @@ 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,
|
||||
|
||||
@@ -108,10 +108,11 @@ When forming your description:
|
||||
## Response Field Guidelines
|
||||
|
||||
Respond with a JSON object matching the provided schema. Field-specific guidance:
|
||||
- `observations`: Include the very start of the activity — for example, a vehicle entering the frame or pulling into the driveway — even if it lasts only a few frames and the rest of the clip is dominated by a longer activity. Include each arrival, departure, object handled, and notable change in position or state. Each item is a single concrete fact written as a complete sentence.
|
||||
- `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`: 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."
|
||||
- `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.
|
||||
- `shortSummary`: Briefly summarize the primary activity across the observations.
|
||||
- `potential_threat_level`: Must be consistent with your scene description and the activity patterns above.
|
||||
{get_concern_prompt()}
|
||||
|
||||
## Sequence Details
|
||||
|
||||
|
||||
+58
-4
@@ -1,5 +1,7 @@
|
||||
"""Ollama Provider for Frigate AI."""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Optional
|
||||
@@ -16,6 +18,41 @@ 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."""
|
||||
@@ -31,6 +68,12 @@ 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 = {
|
||||
@@ -39,7 +82,11 @@ class OllamaClient(GenAIClient):
|
||||
}
|
||||
|
||||
try:
|
||||
client = ApiClient(host=self.genai_config.base_url, timeout=self.timeout)
|
||||
client = ApiClient(
|
||||
host=self.genai_config.base_url,
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
# ensure the model is available locally
|
||||
response = client.show(self.genai_config.model)
|
||||
if response.get("error"):
|
||||
@@ -166,7 +213,9 @@ class OllamaClient(GenAIClient):
|
||||
return []
|
||||
try:
|
||||
client = ApiClient(
|
||||
host=self.genai_config.base_url, timeout=self.timeout
|
||||
host=self.genai_config.base_url,
|
||||
timeout=self.timeout,
|
||||
headers=self._auth_headers(),
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
@@ -195,10 +244,13 @@ class OllamaClient(GenAIClient):
|
||||
"""Build request_messages and params for chat (sync or stream)."""
|
||||
request_messages = []
|
||||
for msg in messages:
|
||||
msg_dict = {
|
||||
content, images = _normalize_multimodal_content(msg.get("content", ""))
|
||||
msg_dict: dict[str, Any] = {
|
||||
"role": msg.get("role"),
|
||||
"content": msg.get("content", ""),
|
||||
"content": content if content is not None else "",
|
||||
}
|
||||
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"):
|
||||
@@ -344,6 +396,7 @@ 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)
|
||||
@@ -359,6 +412,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""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()
|
||||
@@ -8,7 +8,6 @@ import os
|
||||
import queue
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any, Optional
|
||||
@@ -882,7 +881,7 @@ class Birdseye:
|
||||
coordinates = self.birdseye_manager.get_camera_coordinates()
|
||||
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
|
||||
if self._idle_interval:
|
||||
now = time.monotonic()
|
||||
now = datetime.datetime.now().timestamp()
|
||||
is_idle = len(self.birdseye_manager.camera_layout) == 0
|
||||
if (
|
||||
is_idle
|
||||
|
||||
@@ -349,6 +349,13 @@ 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,14 +361,17 @@ class PreviewRecorder:
|
||||
small_frame,
|
||||
cv2.COLOR_YUV2BGR_I420,
|
||||
)
|
||||
cv2.imwrite(
|
||||
get_cache_image_name(self.camera_name, frame_time),
|
||||
cache_path = get_cache_image_name(self.camera_name, frame_time)
|
||||
|
||||
if not cv2.imwrite(
|
||||
cache_path,
|
||||
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,9 +351,11 @@ class RecordingCleanup(threading.Thread):
|
||||
)
|
||||
.where(
|
||||
ReviewSegment.camera == camera,
|
||||
# need to ensure segments for all reviews starting
|
||||
# before the expire date are included
|
||||
ReviewSegment.start_time < motion_expire_date,
|
||||
# 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,
|
||||
)
|
||||
.order_by(ReviewSegment.start_time)
|
||||
.namedtuples()
|
||||
|
||||
+192
-118
@@ -13,6 +13,7 @@ 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
|
||||
@@ -22,13 +23,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
|
||||
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__)
|
||||
@@ -242,111 +243,172 @@ 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]:
|
||||
"""Run an FFmpeg export command, parsing progress events from stderr.
|
||||
"""Delegate to the shared helper, mapping percent → (step, percent).
|
||||
|
||||
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`.
|
||||
Returns ``(returncode, captured_stderr)``.
|
||||
"""
|
||||
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
|
||||
|
||||
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",
|
||||
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,
|
||||
)
|
||||
|
||||
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:
|
||||
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 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:
|
||||
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"
|
||||
)
|
||||
return datetime.datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def _chapter_metadata_path(self) -> str:
|
||||
return os.path.join(CACHE_DIR, f"export_chapters_{self.export_id}.txt")
|
||||
|
||||
def _build_chapter_metadata_file(self, recordings: list) -> Optional[str]:
|
||||
"""Write an FFmpeg metadata file with chapters for review items in range.
|
||||
|
||||
Chapter offsets are computed in *output time*: the VOD endpoint
|
||||
concatenates recording clips back-to-back, so wall-clock gaps
|
||||
between recordings collapse in the produced video. We walk the
|
||||
same recording rows that feed the playlist and convert each
|
||||
review item's wall-clock boundaries into output-time offsets.
|
||||
Returns ``None`` when there are no recordings, no review items,
|
||||
or any chapter would have zero output duration.
|
||||
"""
|
||||
if not recordings:
|
||||
return None
|
||||
|
||||
windows: list[tuple[float, float, float]] = []
|
||||
output_offset = 0.0
|
||||
for rec in recordings:
|
||||
clipped_start = max(float(rec.start_time), float(self.start_time))
|
||||
clipped_end = min(float(rec.end_time), float(self.end_time))
|
||||
if clipped_end <= clipped_start:
|
||||
continue
|
||||
windows.append((clipped_start, clipped_end, output_offset))
|
||||
output_offset += clipped_end - clipped_start
|
||||
|
||||
if not windows:
|
||||
return None
|
||||
|
||||
try:
|
||||
review_rows = list(
|
||||
ReviewSegment.select(
|
||||
ReviewSegment.start_time,
|
||||
ReviewSegment.end_time,
|
||||
ReviewSegment.severity,
|
||||
ReviewSegment.data,
|
||||
)
|
||||
.where(
|
||||
ReviewSegment.start_time.between(self.start_time, self.end_time)
|
||||
| ReviewSegment.end_time.between(self.start_time, self.end_time)
|
||||
| (
|
||||
(self.start_time > ReviewSegment.start_time)
|
||||
& (self.end_time < ReviewSegment.end_time)
|
||||
)
|
||||
)
|
||||
.where(ReviewSegment.camera == self.camera)
|
||||
.order_by(ReviewSegment.start_time.asc())
|
||||
.iterator()
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to query review segments for export %s", self.export_id
|
||||
)
|
||||
return None
|
||||
|
||||
if not review_rows:
|
||||
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))
|
||||
for w_start, w_end, w_offset in windows:
|
||||
if t < w_start:
|
||||
return w_offset
|
||||
if t <= w_end:
|
||||
return w_offset + (t - w_start)
|
||||
return total_output
|
||||
|
||||
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)
|
||||
|
||||
# Drop chapters that fall entirely in a recording gap, or are
|
||||
# too short to be navigable in a player.
|
||||
if end_out - start_out < 1.0:
|
||||
continue
|
||||
|
||||
data = review.data or {}
|
||||
labels: list[str] = []
|
||||
for obj in data.get("objects") or []:
|
||||
label = str(obj).split("-")[0]
|
||||
if label and label not in labels:
|
||||
labels.append(label)
|
||||
|
||||
title = str(review.severity).capitalize()
|
||||
if labels:
|
||||
title = f"{title}: {', '.join(labels)}"
|
||||
|
||||
chapter_blocks.append(
|
||||
"[CHAPTER]\n"
|
||||
"TIMEBASE=1/1000\n"
|
||||
f"START={int(start_out * 1000)}\n"
|
||||
f"END={int(end_out * 1000)}\n"
|
||||
f"title={title}"
|
||||
)
|
||||
|
||||
if not chapter_blocks:
|
||||
return None
|
||||
|
||||
meta_path = self._chapter_metadata_path()
|
||||
try:
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
f.write(";FFMETADATA1\n")
|
||||
f.write("\n".join(chapter_blocks))
|
||||
f.write("\n")
|
||||
except OSError:
|
||||
logger.exception(
|
||||
"Failed to write chapter metadata file for export %s", self.export_id
|
||||
)
|
||||
return None
|
||||
|
||||
return meta_path
|
||||
|
||||
def save_thumbnail(self, id: str) -> str:
|
||||
thumb_path = os.path.join(CLIPS_DIR, f"export/{id}.webp")
|
||||
|
||||
@@ -387,16 +449,14 @@ class RecordingExporter(threading.Thread):
|
||||
except DoesNotExist:
|
||||
return ""
|
||||
|
||||
diff = self.start_time - preview.start_time
|
||||
minutes = int(diff / 60)
|
||||
seconds = int(diff % 60)
|
||||
diff = max(0.0, float(self.start_time) - float(preview.start_time))
|
||||
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"00:{minutes}:{seconds}",
|
||||
f"{diff:.3f}",
|
||||
"-i",
|
||||
preview.path,
|
||||
"-frames",
|
||||
@@ -422,12 +482,18 @@ 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:
|
||||
@@ -436,6 +502,9 @@ 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 ""
|
||||
|
||||
@@ -451,6 +520,24 @@ class RecordingExporter(threading.Thread):
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
|
||||
recordings = list(
|
||||
Recordings.select(
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(self.start_time, self.end_time)
|
||||
| Recordings.end_time.between(self.start_time, self.end_time)
|
||||
| (
|
||||
(self.start_time > Recordings.start_time)
|
||||
& (self.end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(Recordings.camera == self.camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
.iterator()
|
||||
)
|
||||
|
||||
playlist_lines: list[str] = []
|
||||
if (self.end_time - self.start_time) <= MAX_PLAYLIST_SECONDS:
|
||||
playlist_url = f"http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{self.start_time}/end/{self.end_time}/index.m3u8"
|
||||
@@ -458,32 +545,13 @@ class RecordingExporter(threading.Thread):
|
||||
f"-y -protocol_whitelist pipe,file,http,tcp -i {playlist_url}"
|
||||
)
|
||||
else:
|
||||
# get full set of recordings
|
||||
export_recordings = (
|
||||
Recordings.select(
|
||||
Recordings.start_time,
|
||||
Recordings.end_time,
|
||||
)
|
||||
.where(
|
||||
Recordings.start_time.between(self.start_time, self.end_time)
|
||||
| Recordings.end_time.between(self.start_time, self.end_time)
|
||||
| (
|
||||
(self.start_time > Recordings.start_time)
|
||||
& (self.end_time < Recordings.end_time)
|
||||
)
|
||||
)
|
||||
.where(Recordings.camera == self.camera)
|
||||
.order_by(Recordings.start_time.asc())
|
||||
)
|
||||
|
||||
# Use pagination to process records in chunks
|
||||
# Chunk the recording rows into pages so each playlist line
|
||||
# references a bounded sub-range rather than the full export.
|
||||
page_size = 1000
|
||||
num_pages = (export_recordings.count() + page_size - 1) // page_size
|
||||
|
||||
for page in range(1, num_pages + 1):
|
||||
playlist = export_recordings.paginate(page, page_size)
|
||||
for i in range(0, len(recordings), page_size):
|
||||
chunk = recordings[i : i + page_size]
|
||||
playlist_lines.append(
|
||||
f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(playlist[0].start_time)}/end/{float(playlist[-1].end_time)}/index.m3u8'"
|
||||
f"file 'http://127.0.0.1:{internal_port}/vod/{self.camera}/start/{float(chunk[0].start_time)}/end/{float(chunk[-1].end_time)}/index.m3u8'"
|
||||
)
|
||||
|
||||
ffmpeg_input = "-y -protocol_whitelist pipe,file,http,tcp -f concat -safe 0 -i /dev/stdin"
|
||||
@@ -504,8 +572,12 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
).split(" ")
|
||||
else:
|
||||
chapters_path = self._build_chapter_metadata_file(recordings)
|
||||
chapter_args = (
|
||||
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
|
||||
)
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input} -c copy -movflags +faststart"
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
|
||||
).split(" ")
|
||||
|
||||
# add metadata
|
||||
@@ -691,6 +763,8 @@ class RecordingExporter(threading.Thread):
|
||||
ffmpeg_cmd, playlist_lines, step="encoding_retry"
|
||||
)
|
||||
|
||||
Path(self._chapter_metadata_path()).unlink(missing_ok=True)
|
||||
|
||||
if returncode != 0:
|
||||
logger.error(
|
||||
f"Failed to export {self.playback_source.value} for command {' '.join(ffmpeg_cmd)}"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""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 "cpu" in frigate_config.detectors.keys()
|
||||
assert frigate_config.detectors["cpu"].type == DetectorTypeEnum.cpu
|
||||
assert frigate_config.detectors["cpu"].model.width == 320
|
||||
assert "ov" in frigate_config.detectors.keys()
|
||||
assert frigate_config.detectors["ov"].type == DetectorTypeEnum.openvino
|
||||
assert frigate_config.detectors["ov"].model.width == 300
|
||||
|
||||
@patch("frigate.detectors.detector_config.load_labels")
|
||||
def test_detector_custom_model_path(self, mock_labels):
|
||||
@@ -1005,6 +1005,7 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"detectors": {"cpu": {"type": "cpu"}},
|
||||
"model": {"path": "plus://test"},
|
||||
"cameras": {
|
||||
"back": {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,460 @@
|
||||
"""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,6 +1,9 @@
|
||||
"""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
|
||||
|
||||
@@ -11,6 +14,7 @@ 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(
|
||||
@@ -115,10 +119,9 @@ 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 = exporter._inject_progress_flags(cmd)
|
||||
result = inject_progress_flags(cmd)
|
||||
|
||||
assert result == [
|
||||
"ffmpeg",
|
||||
@@ -133,8 +136,7 @@ class TestProgressFlagInjection(unittest.TestCase):
|
||||
]
|
||||
|
||||
def test_handles_empty_cmd(self) -> None:
|
||||
exporter = _make_exporter()
|
||||
assert exporter._inject_progress_flags([]) == []
|
||||
assert inject_progress_flags([]) == []
|
||||
|
||||
|
||||
class TestFfmpegProgressParsing(unittest.TestCase):
|
||||
@@ -164,7 +166,7 @@ class TestFfmpegProgressParsing(unittest.TestCase):
|
||||
fake_proc.returncode = 0
|
||||
fake_proc.wait = MagicMock(return_value=0)
|
||||
|
||||
with patch("frigate.record.export.sp.Popen", return_value=fake_proc):
|
||||
with patch("frigate.util.ffmpeg.sp.Popen", return_value=fake_proc):
|
||||
returncode, _stderr = exporter._run_ffmpeg_with_progress(
|
||||
["ffmpeg", "-i", "x.m3u8", "/tmp/out.mp4"], "playlist", step="encoding"
|
||||
)
|
||||
@@ -363,6 +365,121 @@ 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()
|
||||
@@ -381,5 +498,56 @@ 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()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""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,8 +7,6 @@ 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):
|
||||
@@ -19,32 +17,76 @@ class TestGpuStats(unittest.TestCase):
|
||||
amd_stats = get_amd_gpu_stats()
|
||||
assert amd_stats == {"gpu": "4.17%", "mem": "60.37%"}
|
||||
|
||||
# @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 %",
|
||||
# }
|
||||
@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_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": "26.08%",
|
||||
"mem": "-%",
|
||||
"compute": "0.0%",
|
||||
"dec": "2.27%",
|
||||
# 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()
|
||||
assert intel_stats == {
|
||||
"gpu": "90.0%",
|
||||
"mem": "-%",
|
||||
"compute": "30.0%",
|
||||
"dec": "60.0%",
|
||||
"clients": {"100": "80.0%", "200": "10.0%"},
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""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,7 +773,9 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
logger.debug(f"Camera {camera} disabled, skipping update")
|
||||
continue
|
||||
|
||||
camera_state = self.camera_states[camera]
|
||||
camera_state = self.camera_states.get(camera)
|
||||
if camera_state is None:
|
||||
continue
|
||||
|
||||
camera_state.update(
|
||||
frame_name, frame_time, current_tracked_objects, motion_boxes, regions
|
||||
|
||||
@@ -330,7 +330,12 @@ class TrackedObject:
|
||||
if self.obj_data["position_changes"] != obj_data["position_changes"]:
|
||||
significant_change = True
|
||||
|
||||
if self.obj_data["attributes"] != obj_data["attributes"]:
|
||||
# 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:
|
||||
significant_change = True
|
||||
|
||||
# if the state changed between stationary and active
|
||||
|
||||
+123
-1
@@ -2,8 +2,9 @@
|
||||
|
||||
import logging
|
||||
import subprocess as sp
|
||||
from typing import Any
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from frigate.const import PROCESS_PRIORITY_LOW
|
||||
from frigate.log import LogPipe
|
||||
|
||||
|
||||
@@ -46,3 +47,124 @@ 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)
|
||||
|
||||
+183
-125
@@ -264,156 +264,214 @@ def get_amd_gpu_stats() -> Optional[dict[str, str]]:
|
||||
return results
|
||||
|
||||
|
||||
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, str]]:
|
||||
"""Get stats using intel_gpu_top.
|
||||
_INTEL_FDINFO_SAMPLE_SECONDS = 1.0
|
||||
|
||||
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)
|
||||
# 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.
|
||||
"""
|
||||
|
||||
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]
|
||||
snapshot: dict = {}
|
||||
|
||||
try:
|
||||
p = sp.run(
|
||||
intel_gpu_top_command,
|
||||
encoding="ascii",
|
||||
capture_output=True,
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
proc_entries = os.listdir("/proc")
|
||||
except OSError:
|
||||
return snapshot
|
||||
|
||||
# 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())
|
||||
for entry in proc_entries:
|
||||
if not entry.isdigit():
|
||||
continue
|
||||
|
||||
fdinfo_dir = f"/proc/{entry}/fdinfo"
|
||||
try:
|
||||
data = json.loads(f"[{output}]")
|
||||
except json.JSONDecodeError:
|
||||
return get_stats_manually(output)
|
||||
fds = os.listdir(fdinfo_dir)
|
||||
except (FileNotFoundError, PermissionError, NotADirectoryError, 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]] = {}
|
||||
for fd in fds:
|
||||
try:
|
||||
with open(f"{fdinfo_dir}/{fd}") as f:
|
||||
content = f.read()
|
||||
except (FileNotFoundError, PermissionError, OSError):
|
||||
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))
|
||||
if "drm-driver" not in content:
|
||||
continue
|
||||
|
||||
global_engine = block.get("engines")
|
||||
fields: dict[str, str] = {}
|
||||
for line in content.splitlines():
|
||||
key, sep, value = line.partition(":")
|
||||
if sep:
|
||||
fields[key.strip()] = value.strip()
|
||||
|
||||
if global_engine:
|
||||
render_frame = global_engine.get("Render/3D/0", {}).get("busy")
|
||||
video_frame = global_engine.get("Video/0", {}).get("busy")
|
||||
driver = fields.get("drm-driver")
|
||||
if driver not in ("i915", "xe"):
|
||||
continue
|
||||
|
||||
if render_frame is not None:
|
||||
render_global.append(float(render_frame))
|
||||
pdev = fields.get("drm-pdev", "")
|
||||
if target_pdev and pdev != target_pdev:
|
||||
continue
|
||||
|
||||
if video_frame is not None:
|
||||
video_global.append(float(video_frame))
|
||||
client_id = fields.get("drm-client-id")
|
||||
if not client_id:
|
||||
continue
|
||||
|
||||
clients = block.get("clients", {})
|
||||
key = (pdev, client_id, entry)
|
||||
if key in snapshot:
|
||||
continue
|
||||
|
||||
if clients:
|
||||
for client_block in clients.values():
|
||||
pid = client_block["pid"]
|
||||
engines: dict[str, tuple[int, int]] = {}
|
||||
|
||||
if pid not in client_usages:
|
||||
client_usages[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
|
||||
|
||||
# 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)
|
||||
if not engines:
|
||||
continue
|
||||
|
||||
client_usages[pid].append(total_busy)
|
||||
snapshot[key] = {"driver": driver, "pid": entry, "engines": engines}
|
||||
|
||||
# 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)}%"
|
||||
return snapshot
|
||||
|
||||
results["mem"] = "-%"
|
||||
|
||||
# Compute: Render/3D engine (compute/shader workloads and QSV encode)
|
||||
if render_global:
|
||||
results["compute"] = f"{round(sum(render_global) / len(render_global), 2)}%"
|
||||
def get_intel_gpu_stats(intel_gpu_device: Optional[str]) -> Optional[dict[str, Any]]:
|
||||
"""Get stats by reading DRM fdinfo files.
|
||||
|
||||
# Decoder: Video engine (fixed-function codec)
|
||||
if video_global:
|
||||
results["dec"] = f"{round(sum(video_global) / len(video_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)
|
||||
|
||||
# Per-client GPU usage (sum of all engines per process)
|
||||
if client_usages:
|
||||
results["clients"] = {}
|
||||
snapshot_a = _read_intel_drm_fdinfo(target_pdev)
|
||||
if not snapshot_a:
|
||||
return None
|
||||
|
||||
for pid, samples in client_usages.items():
|
||||
if samples:
|
||||
results["clients"][pid] = (
|
||||
f"{round(sum(samples) / len(samples), 2)}%"
|
||||
)
|
||||
start = time.monotonic()
|
||||
time.sleep(_INTEL_FDINFO_SAMPLE_SECONDS)
|
||||
elapsed_ns = (time.monotonic() - start) * 1e9
|
||||
|
||||
return results
|
||||
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
|
||||
|
||||
|
||||
def get_openvino_npu_stats() -> Optional[dict[str, str]]:
|
||||
|
||||
+19
-21
@@ -438,34 +438,32 @@ 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": []}
|
||||
|
||||
# find the best object for each attribute to be assigned to
|
||||
# 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
|
||||
all_objects: list[dict[str, Any]] = object_tracker.tracked_objects.values()
|
||||
for attributes in attribute_detections.values():
|
||||
for attribute in attributes:
|
||||
filtered_objects = filter(
|
||||
lambda o: attribute.label in attributes_map.get(o["label"], []),
|
||||
all_objects,
|
||||
)
|
||||
selected_object_id = attribute.find_best_object(filtered_objects)
|
||||
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()
|
||||
)
|
||||
if selected_object_id is not None:
|
||||
detections[selected_object_id]["attributes"].append(
|
||||
attribute.get_tracking_data()
|
||||
)
|
||||
|
||||
# debug object tracking
|
||||
if False:
|
||||
|
||||
@@ -174,6 +174,7 @@ 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
|
||||
@@ -323,6 +324,22 @@ 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,88 +1,95 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "rmuF9iKWTbdk"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"! pip install -q git+https://github.com/Deci-AI/super-gradients.git"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "NiRCt917KKcL"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.12/dist-packages/super_gradients/training/pretrained_models.py\n",
|
||||
"! sed -i 's/sghub.deci.ai/sg-hub-nv.s3.amazonaws.com/' /usr/local/lib/python3.12/dist-packages/super_gradients/training/utils/checkpoint_utils.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "dTB0jy_NNSFz"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from super_gradients.common.object_names import Models\n",
|
||||
"from super_gradients.conversion import DetectionOutputFormatMode\n",
|
||||
"from super_gradients.training import models\n",
|
||||
"\n",
|
||||
"model = models.get(Models.YOLO_NAS_S, pretrained_weights=\"coco\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "GymUghyCNXem"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# export the model for compatibility with Frigate\n",
|
||||
"\n",
|
||||
"model.export(\"yolo_nas_s.onnx\",\n",
|
||||
" output_predictions_format=DetectionOutputFormatMode.FLAT_FORMAT,\n",
|
||||
" max_predictions_per_image=20,\n",
|
||||
" num_pre_nms_predictions=300,\n",
|
||||
" confidence_threshold=0.4,\n",
|
||||
" input_image_shape=(320,320),\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "uBhXV5g4Nh42"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from google.colab import files\n",
|
||||
"\n",
|
||||
"files.download('yolo_nas_s.onnx')"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"colab": {
|
||||
"provenance": []
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {
|
||||
"id": "runtime-notice"
|
||||
},
|
||||
"source": [
|
||||
"**Before running:** go to **Runtime → Change runtime type → Fallback runtime version: 2025.07** (Python 3.11). The current Colab default (Python 3.12+) is incompatible with `super-gradients`."
|
||||
]
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0
|
||||
}
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "rmuF9iKWTbdk"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"! pip install -q \"jedi>=0.16\"\n",
|
||||
"! pip install -q git+https://github.com/Deci-AI/super-gradients.git"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "NiRCt917KKcL"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": "! sed -i 's/sghub\\.deci\\.ai/d2gjn4b69gu75n.cloudfront.net/g; s/sg-hub-nv\\.s3\\.amazonaws\\.com/d2gjn4b69gu75n.cloudfront.net/g' /usr/local/lib/python*/dist-packages/super_gradients/training/pretrained_models.py\n! sed -i 's/sghub\\.deci\\.ai/d2gjn4b69gu75n.cloudfront.net/g; s/sg-hub-nv\\.s3\\.amazonaws\\.com/d2gjn4b69gu75n.cloudfront.net/g' /usr/local/lib/python*/dist-packages/super_gradients/training/utils/checkpoint_utils.py"
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "dTB0jy_NNSFz"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from super_gradients.common.object_names import Models\n",
|
||||
"from super_gradients.conversion import DetectionOutputFormatMode\n",
|
||||
"from super_gradients.training import models\n",
|
||||
"\n",
|
||||
"model = models.get(Models.YOLO_NAS_S, pretrained_weights=\"coco\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "GymUghyCNXem"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# export the model for compatibility with Frigate\n",
|
||||
"\n",
|
||||
"model.export(\"yolo_nas_s.onnx\",\n",
|
||||
" output_predictions_format=DetectionOutputFormatMode.FLAT_FORMAT,\n",
|
||||
" max_predictions_per_image=20,\n",
|
||||
" num_pre_nms_predictions=300,\n",
|
||||
" confidence_threshold=0.4,\n",
|
||||
" input_image_shape=(320,320),\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {
|
||||
"id": "uBhXV5g4Nh42"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from google.colab import files\n",
|
||||
"\n",
|
||||
"files.download('yolo_nas_s.onnx')"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"colab": {
|
||||
"provenance": []
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0
|
||||
}
|
||||
@@ -69,17 +69,18 @@ test.describe("Navigation — conditional items @critical", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("/chat is hidden when genai.model is none (desktop)", async ({
|
||||
test("/chat is hidden when no agent has the chat role (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
genai: {
|
||||
enabled: false,
|
||||
provider: "ollama",
|
||||
model: "none",
|
||||
base_url: "",
|
||||
descriptions_only: {
|
||||
provider: "ollama",
|
||||
model: "llava",
|
||||
roles: ["descriptions"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -89,12 +90,20 @@ test.describe("Navigation — conditional items @critical", () => {
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("/chat is visible when genai.model is set (desktop)", async ({
|
||||
test("/chat is visible when an agent has the chat role (desktop)", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop sidebar");
|
||||
await frigateApp.installDefaults({
|
||||
config: { genai: { enabled: true, model: "llava" } },
|
||||
config: {
|
||||
genai: {
|
||||
chat_agent: {
|
||||
provider: "ollama",
|
||||
model: "llava",
|
||||
roles: ["chat"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
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 Replay Session/i,
|
||||
name: /No Active Debug 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 Replay Session/i,
|
||||
name: /No Active Debug 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 Replay Session/i,
|
||||
name: /No Active Debug Replay Session/i,
|
||||
}),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
Generated
+4
-4
@@ -117,7 +117,7 @@
|
||||
"monaco-editor": "^0.52.2",
|
||||
"msw": "^2.3.5",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.9",
|
||||
@@ -11605,9 +11605,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@
|
||||
"monaco-editor": "^0.52.2",
|
||||
"msw": "^2.3.5",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.12",
|
||||
"postcss": "^8.5.8",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.9",
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
{}
|
||||
{
|
||||
"auth": {
|
||||
"label": "Автентикация",
|
||||
"session_length": {
|
||||
"label": "Продължителност на сесията"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,8 @@
|
||||
"classification": "Classificació",
|
||||
"chat": "Xat",
|
||||
"actions": "Accions",
|
||||
"profiles": "Perfils"
|
||||
"profiles": "Perfils",
|
||||
"features": "Característiques"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": {
|
||||
|
||||
@@ -60,15 +60,76 @@
|
||||
"noVaildTimeSelected": "No s'ha seleccionat un rang de temps vàlid",
|
||||
"failed": "No s'ha pogut inciar l'exportació: {{error}}"
|
||||
},
|
||||
"view": "Vista"
|
||||
"view": "Vista",
|
||||
"queued": "Exporta a la cua. Mostra el progrés a la pàgina d'exportacions.",
|
||||
"batchSuccess_one": "S'ha iniciat l'exportació 1. Obrint el cas ara.",
|
||||
"batchSuccess_many": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"batchSuccess_other": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"batchPartial": "S'han iniciat {{successful}} de {{total}} exportacions. Càmeres fallides: {{failedCameras}}",
|
||||
"batchFailed": "No s'han pogut iniciar {{total}} exportacions. Càmeres fallides: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "Exporta a la cua 1. Obrint el cas ara.",
|
||||
"batchQueuedSuccess_many": "{{count}} exportacions a la cua. Obrint el cas ara.",
|
||||
"batchQueuedSuccess_other": "{{count}} exportacions a la cua. Obrint el cas ara.",
|
||||
"batchQueuedPartial": "{{successful}} de {{total}} exportacions a la cua. Càmeres fallides: {{failedCameras}}",
|
||||
"batchQueueFailed": "No s'han pogut posar a la cua {{total}} exportacions. Càmeres fallides: {{failedCameras}}"
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Guardar exportació",
|
||||
"previewExport": "Previsualitzar exportació"
|
||||
"previewExport": "Previsualitzar exportació",
|
||||
"queueingExport": "S'està fent la cua de l'exportació...",
|
||||
"useThisRange": "Utilitza aquest interval"
|
||||
},
|
||||
"case": {
|
||||
"label": "Cas",
|
||||
"placeholder": "Selecciona un cas"
|
||||
"placeholder": "Selecciona un cas",
|
||||
"newCaseOption": "Crea un cas no",
|
||||
"newCaseNamePlaceholder": "Nom de cas nou",
|
||||
"newCaseDescriptionPlaceholder": "Descripció del cas",
|
||||
"nonAdminHelp": "Es crearà un nou cas per a aquestes exportacions."
|
||||
},
|
||||
"queueing": "S'està fent la cua de l'exportació...",
|
||||
"tabs": {
|
||||
"export": "Càmera única",
|
||||
"multiCamera": "Multicàmera"
|
||||
},
|
||||
"multiCamera": {
|
||||
"timeRange": "Interval de temps",
|
||||
"selectFromTimeline": "Selecciona des de la línia de temps",
|
||||
"cameraSelection": "Càmeres",
|
||||
"cameraSelectionHelp": "Les càmeres amb objectes rastrejats en aquest interval de temps estan preseleccionades",
|
||||
"checkingActivity": "Comprovant l'activitat de la càmera...",
|
||||
"noCameras": "No hi ha càmeres disponibles",
|
||||
"detectionCount_one": "1 objecte rastrejat",
|
||||
"detectionCount_many": "{{count}} objectes rastrejats",
|
||||
"detectionCount_other": "{{count}} objectes rastrejats",
|
||||
"nameLabel": "Nom de l'exportació",
|
||||
"namePlaceholder": "Nom base opcional per a aquestes exportacions",
|
||||
"queueingButton": "S'estan posant a la cua les exportacions...",
|
||||
"exportButton_one": "Exporta 1 càmera",
|
||||
"exportButton_many": "Exporta {{count}} càmeres",
|
||||
"exportButton_other": "Exporta {{count}} càmeres"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "Exporta {{count}} ressenyes",
|
||||
"title_many": "Exporta {{count}} ressenyes",
|
||||
"title_other": "Exporta {{count}} ressenyes",
|
||||
"description": "Exporta cada revisió seleccionada. Totes les exportacions s'agruparan en un sol cas.",
|
||||
"descriptionNoCase": "Exporta cada revisió seleccionada.",
|
||||
"caseNamePlaceholder": "Exporta la revisió - {{date}}",
|
||||
"exportButton_one": "Exporta {{count}} ressenyes",
|
||||
"exportButton_many": "Exporta {{count}} ressenyes",
|
||||
"exportButton_other": "Exporta {{count}} ressenyes",
|
||||
"exportingButton": "S'està exportant...",
|
||||
"toast": {
|
||||
"started_one": "S'ha iniciat l'exportació 1. Obrint el cas ara.",
|
||||
"started_many": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"started_other": "S'han iniciat {{count}} exportacions. Obrint el cas ara.",
|
||||
"startedNoCase_one": "S'ha iniciat l'exportació 1.",
|
||||
"startedNoCase_many": "S'han iniciat {{count}} exportacions.",
|
||||
"startedNoCase_other": "S'han iniciat {{count}} exportacions.",
|
||||
"partial": "S'han iniciat {{successful}} de {{total}} exportacions. Ha fallat: {{failedItems}}",
|
||||
"failed": "No s'han pogut iniciar {{total}} exportacions. Ha fallat: {{failedItems}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
@@ -116,6 +177,14 @@
|
||||
"success": "Els enregistraments de vídeo associats als elements de revisió seleccionats s’han suprimit correctament.",
|
||||
"error": "No s'ha pogut suprimir: {{error}}"
|
||||
}
|
||||
},
|
||||
"shareTimestamp": {
|
||||
"label": "Comparteix la marca horària",
|
||||
"title": "Comparteix la marca horària",
|
||||
"description": "Comparteix un URL amb marca horària de la posició actual del jugador o tria una marca horària personalitzada. Tingueu en compte que aquest no és un URL de compartició pública i només és accessible per als usuaris amb accés a Frigate i aquesta càmera.",
|
||||
"custom": "Marca horària personalitzada",
|
||||
"button": "Comparteix l'URL de la marca horària",
|
||||
"shareTitle": "Marca de temps de revisió de Frigate: {{camera}}"
|
||||
}
|
||||
},
|
||||
"imagePicker": {
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"noPreviewFoundFor": "No s'ha trobat cap previsualització per a {{cameraName}}",
|
||||
"submitFrigatePlus": {
|
||||
"title": "Enviar aquesta imatge a Frigate+?",
|
||||
"submit": "Enviar"
|
||||
"submit": "Enviar",
|
||||
"previewError": "No s'ha pogut carregar la vista prèvia de la instantània. És possible que l'enregistrament no estigui disponible en aquest moment."
|
||||
},
|
||||
"livePlayerRequiredIOSVersion": "Es requereix iOS 17.1 o superior per a aquest tipus de reproducció en directe.",
|
||||
"streamOffline": {
|
||||
|
||||
@@ -1951,7 +1951,7 @@
|
||||
},
|
||||
"roles": {
|
||||
"label": "Rols",
|
||||
"description": "Funcions genAI (eines, visió, incrustacions); un proveïdor per rol."
|
||||
"description": "Rols de GenAI (xat, descripcions, incrustacions); un proveïdor per rol."
|
||||
},
|
||||
"provider_options": {
|
||||
"label": "Opcions del proveïdor",
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
},
|
||||
"documentTitle": "Revisió - Frigate",
|
||||
"recordings": {
|
||||
"documentTitle": "Enregistraments - Frigate"
|
||||
"documentTitle": "Enregistraments - Frigate",
|
||||
"invalidSharedLink": "No s'ha pogut obrir l'enllaç d'enregistrament amb marques de temps a causa d'un error d'anàlisi.",
|
||||
"invalidSharedCamera": "No s'ha pogut obrir l'enllaç d'enregistrament amb marques de temps a causa d'una càmera desconeguda o no autoritzada."
|
||||
},
|
||||
"calendarFilter": {
|
||||
"last24Hours": "Últimes 24 hores"
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
"dialog": {
|
||||
"confirmDelete": {
|
||||
"title": "Confirmar la supressió",
|
||||
"desc": "Eliminant aquest objecte seguit borrarà l'snapshot, qualsevol embedding gravat, i qualsevol detall de seguiment. Les imatges gravades d'aquest objecte seguit en l'historial <em>NO</em> seràn eliminades.<br /><br />Estas segur que vols continuar?"
|
||||
"desc": "Suprimir aquest objecte rastrejat elimina la instantània, qualsevol incrustació desada, i qualsevol entrada de detalls de seguiment associada. Les imatges gravades d'aquest objecte seguit en l'historial <em>NO</em> seràn eliminades.<br /><br />Estas segur que vols continuar?"
|
||||
},
|
||||
"toast": {
|
||||
"error": "S'ha produït un error en suprimir aquest objecte rastrejat: {{errorMessage}}"
|
||||
@@ -289,7 +289,10 @@
|
||||
"zones": "Zones",
|
||||
"ratio": "Ràtio",
|
||||
"area": "Àrea",
|
||||
"score": "Puntuació"
|
||||
"score": "Puntuació",
|
||||
"computedScore": "Puntuació calculada",
|
||||
"topScore": "Puntuació superior",
|
||||
"toggleAdvancedScores": "Commuta les puntuacions avançades"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"toast": {
|
||||
"error": {
|
||||
"renameExportFailed": "Error al canviar el nom de l’exportació: {{errorMessage}}",
|
||||
"assignCaseFailed": "No s'ha pogut actualitzar l'assignació de cas:{{errorMessage}}"
|
||||
"assignCaseFailed": "No s'ha pogut actualitzar l'assignació de cas:{{errorMessage}}",
|
||||
"caseSaveFailed": "No s'ha pogut desar el cas: {{errorMessage}}",
|
||||
"caseDeleteFailed": "No s'ha pogut suprimir el cas: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
@@ -22,7 +24,8 @@
|
||||
"downloadVideo": "Baixa el vídeo",
|
||||
"editName": "Edita el nom",
|
||||
"deleteExport": "Suprimeix l'exportació",
|
||||
"assignToCase": "Afegeix al cas"
|
||||
"assignToCase": "Afegeix al cas",
|
||||
"removeFromCase": "Elimina del cas"
|
||||
},
|
||||
"headings": {
|
||||
"cases": "Casos",
|
||||
@@ -35,5 +38,91 @@
|
||||
"newCaseOption": "Crea un cas nou",
|
||||
"nameLabel": "Nom del cas",
|
||||
"descriptionLabel": "Descripció"
|
||||
},
|
||||
"toolbar": {
|
||||
"newCase": "Cas nou",
|
||||
"addExport": "Afegeix una exportació",
|
||||
"editCase": "Edita el cas",
|
||||
"deleteCase": "Suprimeix el cas"
|
||||
},
|
||||
"deleteCase": {
|
||||
"label": "Suprimeix el cas",
|
||||
"desc": "Esteu segur que voleu suprimir {{caseName}}?",
|
||||
"descKeepExports": "Les exportacions continuaran estant disponibles com a exportacions sense categoria.",
|
||||
"descDeleteExports": "Totes les exportacions en aquest cas s'eliminaran permanentment.",
|
||||
"deleteExports": "Elimina també les exportacions"
|
||||
},
|
||||
"caseCard": {
|
||||
"emptyCase": "Encara no hi ha exportacions"
|
||||
},
|
||||
"jobCard": {
|
||||
"defaultName": "Exportació de {{camera}}",
|
||||
"queued": "En cua",
|
||||
"running": "En execució",
|
||||
"preparing": "Preparant",
|
||||
"copying": "Copiant",
|
||||
"encoding": "Codificant",
|
||||
"encodingRetry": "Codificant (reintent)",
|
||||
"finalizing": "Finalitzant"
|
||||
},
|
||||
"caseView": {
|
||||
"noDescription": "Sense descripció",
|
||||
"createdAt": "{{value}} creat",
|
||||
"exportCount_one": "1 exportació",
|
||||
"exportCount_other": "{{count}} exportacions",
|
||||
"cameraCount_one": "1 càmera",
|
||||
"cameraCount_other": "{{count}} càmeres",
|
||||
"showMore": "Mostra'n més",
|
||||
"showLess": "Mostra menys",
|
||||
"emptyTitle": "Aquest cas és buit",
|
||||
"emptyDescription": "Afegeix les exportacions no categoritzades existents per mantenir el cas organitzat.",
|
||||
"emptyDescriptionNoExports": "Encara no hi ha exportacions sense categoria per afegir."
|
||||
},
|
||||
"caseEditor": {
|
||||
"createTitle": "Crea un cas",
|
||||
"editTitle": "Edita el cas",
|
||||
"namePlaceholder": "Nom del cas",
|
||||
"descriptionPlaceholder": "Afegeix notes o context per a aquest cas"
|
||||
},
|
||||
"addExportDialog": {
|
||||
"title": "Afegeix l'exportació a {{caseName}}",
|
||||
"searchPlaceholder": "Cerca exportacions sense categoria",
|
||||
"empty": "No hi ha exportacions sense categoria que coincideixin amb aquesta cerca.",
|
||||
"addButton_one": "Afegeix 1 exportació",
|
||||
"addButton_other": "Afegeix {{count}} exportacions",
|
||||
"adding": "S'està afegint..."
|
||||
},
|
||||
"selected_one": "{{count}} seleccionats",
|
||||
"selected_other": "{{count}} seleccionats",
|
||||
"bulkActions": {
|
||||
"addToCase": "Afegeix al cas",
|
||||
"moveToCase": "Mou al cas",
|
||||
"removeFromCase": "Elimina del cas",
|
||||
"delete": "Suprimeix",
|
||||
"deleteNow": "Suprimeix ara"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"title": "Suprimeix les exportacions",
|
||||
"desc_one": "Esteu segur que voleu suprimir {{count}} l'exportació?",
|
||||
"desc_other": "steu segur que voleu suprimir {{count}} exportacions?"
|
||||
},
|
||||
"bulkRemoveFromCase": {
|
||||
"title": "Elimina del cas",
|
||||
"desc_one": "Voleu suprimir {{count}} d'aquest cas?",
|
||||
"desc_other": "Voleu eliminar {{count}} exportacions d'aquest cas?",
|
||||
"descKeepExports": "Les exportacions es mouran a sense categoria.",
|
||||
"descDeleteExports": "Les exportacions s'eliminaran permanentment.",
|
||||
"deleteExports": "Suprimeix les exportacions"
|
||||
},
|
||||
"bulkToast": {
|
||||
"success": {
|
||||
"delete": "Exportacions suprimides amb èxit",
|
||||
"reassign": "Assignació de cas actualitzada amb èxit",
|
||||
"remove": "S'han eliminat les exportacions del cas"
|
||||
},
|
||||
"error": {
|
||||
"deleteFailed": "No s'han pogut suprimir les exportacions: {{errorMessage}}",
|
||||
"reassignFailed": "No s'ha pogut actualitzar l'assignació de cas: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"uploadFace": "Puja una imatge del rostre",
|
||||
"nextSteps": "Següents passos",
|
||||
"description": {
|
||||
"uploadFace": "Puja una imatge de {{name}} que mostri el seu rostre de cares. No cal que la imatge estigui retallada només al rostre."
|
||||
"uploadFace": "Pugeu una imatge de {{name}} que mostra la seva cara des d'un angle frontal. La imatge no necessita ser retallada a la seva cara."
|
||||
}
|
||||
},
|
||||
"selectFace": "Seleccionar rostre",
|
||||
|
||||
@@ -1280,7 +1280,8 @@
|
||||
},
|
||||
"hikvision": {
|
||||
"substreamWarning": "El substream 1 està bloquejat a una resolució baixa. Moltes càmeres Hikvision suporten subfluxos addicionals que han d'estar habilitats a la configuració de la càmera. Es recomana comprovar i utilitzar aquests corrents si estan disponibles."
|
||||
}
|
||||
},
|
||||
"resolutionUnknown": "La resolució d'aquest flux no s'ha pogut investigar. Heu d'establir manualment la resolució de detecció a Configuració o a la configuració."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1297,7 +1298,13 @@
|
||||
"enableDesc": "Inhabilita temporalment una càmera habilitada fins que es reiniciï Frigate. La inhabilitació d'una càmera atura completament el processament de Frigate dels fluxos d'aquesta càmera. La detecció, l'enregistrament i la depuració no estaran disponibles.<br /> <em>Nota: això no desactiva les retransmissions de go2rtc.</em>",
|
||||
"disableLabel": "Càmeres inhabilitades",
|
||||
"disableDesc": "Habilita una càmera que actualment no és visible a la interfície d'usuari i està desactivada a la configuració. Es requereix un reinici de Frigate després d'activar-la.",
|
||||
"enableSuccess": "{{cameraName}} activat a la configuració. Reinicia Frigate per aplicar els canvis."
|
||||
"enableSuccess": "{{cameraName}} activat a la configuració. Reinicia Frigate per aplicar els canvis.",
|
||||
"friendlyName": {
|
||||
"edit": "Edita el nom de la pantalla de la càmera",
|
||||
"title": "Edita el nom de la pantalla",
|
||||
"description": "Estableix el nom amigable que es mostra per a aquesta càmera a tota la interfície d'usuari de la Fragata. Deixeu-ho en blanc per utilitzar l'ID de la càmera.",
|
||||
"rename": "Canvia el nom"
|
||||
}
|
||||
},
|
||||
"cameraConfig": {
|
||||
"add": "Afegeix una càmera",
|
||||
@@ -1659,7 +1666,16 @@
|
||||
"empty": "No hi ha etiquetes disponibles",
|
||||
"allNonAlertDetections": "Totes les activitats no alertes s'inclouran com a deteccions."
|
||||
},
|
||||
"addCustomLabel": "Afegeix una etiqueta personalitzada..."
|
||||
"addCustomLabel": "Afegeix una etiqueta personalitzada...",
|
||||
"genaiModel": {
|
||||
"placeholder": "Selecciona el model…",
|
||||
"search": "Cerca models…",
|
||||
"noModels": "No hi ha models disponibles"
|
||||
},
|
||||
"knownPlates": {
|
||||
"namePlaceholder": "per exemple. Cotxe de la parella",
|
||||
"platePlaceholder": "Matricula o regex"
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
"title": "Configuració global",
|
||||
|
||||
@@ -64,20 +64,73 @@
|
||||
"toast": {
|
||||
"error": {
|
||||
"endTimeMustAfterStartTime": "Die Endzeit darf nicht vor der Startzeit liegen",
|
||||
"failed": "Fehler beim Starten des Exports: {{error}}",
|
||||
"failed": "Fehler beim Export in die Warteschlange: {{error}}",
|
||||
"noVaildTimeSelected": "Kein gültiger Zeitraum ausgewählt"
|
||||
},
|
||||
"success": "Export erfolgreich gestartet. Die Datei befindet sich auf der Exportseite.",
|
||||
"view": "Ansicht"
|
||||
"view": "Ansicht",
|
||||
"queued": "Export in Warteschlange gestellt. Fortschritt auf der Exportseite verfolgen.",
|
||||
"batchSuccess_one": "1 Export gestartet. Öffne den Fall jetzt.",
|
||||
"batchSuccess_other": "{{count}} Exports gestartet. Öffne den Fall jetzt.",
|
||||
"batchPartial": "{{successful}} von {{total}} Exporten gestartet. Fehlgeschlagene Kameras: {{failedCameras}}",
|
||||
"batchFailed": "Fehler beim Starten der {{total}} Exporte. Fehlgeschlagene Kameras: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "1 Export in die Warteschlange gestellt. Fall wird jetzt geöffnet.",
|
||||
"batchQueuedSuccess_other": "{{count}} Exporte in der Warteschlange. Fall wird jetzt geöffnet.",
|
||||
"batchQueuedPartial": "{{successful}} von {{total}} Exporten in die Warteschlange gestellt. Fehlerhafte Kameras: {{failedCameras}}",
|
||||
"batchQueueFailed": "Fehler beim Einreihen von {{total}} Exporten in die Warteschlange. Fehlerhafte Kameras: {{failedCameras}}"
|
||||
},
|
||||
"fromTimeline": {
|
||||
"saveExport": "Export speichern",
|
||||
"previewExport": "Exportvorschau"
|
||||
"previewExport": "Exportvorschau",
|
||||
"queueingExport": "Export wird in die Warteschlange gestellt...",
|
||||
"useThisRange": "Nutzen Sie diesen Bereich"
|
||||
},
|
||||
"export": "Exportieren",
|
||||
"case": {
|
||||
"label": "Fall",
|
||||
"placeholder": "Einen Fall auswählen"
|
||||
"placeholder": "Einen Fall auswählen",
|
||||
"newCaseOption": "Einen neuen Fall erstellen",
|
||||
"newCaseNamePlaceholder": "Neuer Fallname",
|
||||
"newCaseDescriptionPlaceholder": "Fall Beschreibung",
|
||||
"nonAdminHelp": "Für diese Exporte wird ein neuer Fall angelegt."
|
||||
},
|
||||
"queueing": "Export wird in die Warteschlange gestellt...",
|
||||
"tabs": {
|
||||
"export": "Einzelne Kamera",
|
||||
"multiCamera": "Mehrere-Kameras"
|
||||
},
|
||||
"multiCamera": {
|
||||
"timeRange": "Zeitbereich",
|
||||
"selectFromTimeline": "Wählen Sie aus der Zeitleiste aus",
|
||||
"cameraSelection": "Kameras",
|
||||
"cameraSelectionHelp": "Kameras, die in diesem Zeitbereich Objekte verfolgen, sind vorausgewählt",
|
||||
"checkingActivity": "Kameraaktivität wird überprüft...",
|
||||
"noCameras": "keine kamaeras verfügbar",
|
||||
"detectionCount_one": "1 verfolgtes Objekt",
|
||||
"detectionCount_other": "{{count}} verfolgtesObjekte",
|
||||
"nameLabel": "Export Name",
|
||||
"namePlaceholder": "Optionaler Basisname für diese Exporte",
|
||||
"queueingButton": "Exporte werden in die Warteschlange gestellt...",
|
||||
"exportButton_one": "Export 1 Kamera",
|
||||
"exportButton_other": "xport {{count}} Kameras"
|
||||
},
|
||||
"multi": {
|
||||
"title_one": "1 Bewertung exportieren",
|
||||
"title_other": "{{count}} Bewertung exportieren",
|
||||
"description": "Exportieren Sie jede ausgewählte Rezension. Alle Exporte werden in einem einzigen Fall zusammengefasst.",
|
||||
"descriptionNoCase": "Jede ausgewählte Bewertung exportieren.",
|
||||
"caseNamePlaceholder": "Export prüfen - {{date}}",
|
||||
"exportButton_one": "1 Bewertung exportieren",
|
||||
"exportButton_other": "{{count}} Bewertung exportieren",
|
||||
"exportingButton": "Exportieren...",
|
||||
"toast": {
|
||||
"started_one": "1 Export gestartet. Fall wird jetzt geöffnet.",
|
||||
"started_other": "{{count}} Exporte gestartet. Fall wird jetzt geöffnet.",
|
||||
"startedNoCase_one": "1 Export gestartet.",
|
||||
"startedNoCase_other": "{{count}} Exports gestartet.",
|
||||
"partial": "{{successful}} von {{total}} Exporten gestartet. Fehlgeschlagen: {{failedItems}}",
|
||||
"failed": "Fehler beim Starten der {{total}} Exporte. Fehler: {{failedItems}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
|
||||
@@ -1710,7 +1710,7 @@
|
||||
},
|
||||
"roles": {
|
||||
"label": "Rollen",
|
||||
"description": "GenAI-Rollen (Tools, Vision, Einbettungen); ein Anbieter pro Rolle."
|
||||
"description": "GenAI-Rollen (Nachrichten, Beschreibung, Einbettungen); ein Anbieter pro Rolle."
|
||||
},
|
||||
"provider_options": {
|
||||
"label": "Anbieter Optionen",
|
||||
|
||||
@@ -282,7 +282,10 @@
|
||||
"zones": "Zonen",
|
||||
"ratio": "Verhältnis",
|
||||
"area": "Bereich",
|
||||
"score": "Bewertung"
|
||||
"score": "Bewertung",
|
||||
"computedScore": "Berechnetes Ergebnis",
|
||||
"topScore": "Bester Treffer",
|
||||
"toggleAdvancedScores": "Erweiterte Ergebnisse umschalten"
|
||||
}
|
||||
},
|
||||
"annotationSettings": {
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"toast": {
|
||||
"error": {
|
||||
"renameExportFailed": "Umbenennen des Exports fehlgeschlagen: {{errorMessage}}",
|
||||
"assignCaseFailed": "Aktualisierung der Fallzuweisung fehlgeschlagen: {{errorMessage}}"
|
||||
"assignCaseFailed": "Aktualisierung der Fallzuweisung fehlgeschlagen: {{errorMessage}}",
|
||||
"caseSaveFailed": "Fehler beim speichern vom Fall: {{errorMessage}}",
|
||||
"caseDeleteFailed": "Fehler beim löschem vom Fall: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
@@ -22,7 +24,8 @@
|
||||
"downloadVideo": "Video herunterladen",
|
||||
"editName": "Name ändern",
|
||||
"deleteExport": "Export löschen",
|
||||
"assignToCase": "Hinzufügen zum Fall"
|
||||
"assignToCase": "Hinzufügen zum Fall",
|
||||
"removeFromCase": "Vom Gehäuse entfernen"
|
||||
},
|
||||
"headings": {
|
||||
"cases": "Fälle",
|
||||
@@ -35,5 +38,91 @@
|
||||
"newCaseOption": "Neuen Fall erstellen",
|
||||
"nameLabel": "Fallname",
|
||||
"descriptionLabel": "Beschreibung"
|
||||
},
|
||||
"toolbar": {
|
||||
"newCase": "Neuer Fall",
|
||||
"addExport": "Zum expotieren hinzufügen",
|
||||
"editCase": "Fall bearbeiten",
|
||||
"deleteCase": "Fall löschen"
|
||||
},
|
||||
"deleteCase": {
|
||||
"label": "Fall löschen",
|
||||
"desc": "Sind sie sich sicher löschen von{{caseName}}?",
|
||||
"descKeepExports": "Exporte bleiben als nicht kategorisierte Exporte verfügbar.",
|
||||
"descDeleteExports": "Alle Exporte werden in diesem Fall endgültig gelöscht.",
|
||||
"deleteExports": "Exporte auch löschen"
|
||||
},
|
||||
"caseCard": {
|
||||
"emptyCase": "Noch keine Exporte"
|
||||
},
|
||||
"jobCard": {
|
||||
"defaultName": "{{camera}} export",
|
||||
"queued": "In der Warteschlange",
|
||||
"running": "läuft",
|
||||
"preparing": "Vorbereitung",
|
||||
"copying": "kopieren",
|
||||
"encoding": "Codierung",
|
||||
"encodingRetry": "Kodierung (Wiederholung)",
|
||||
"finalizing": "Abschließen"
|
||||
},
|
||||
"caseView": {
|
||||
"noDescription": "keine Beschreibung",
|
||||
"createdAt": "Erstellt {{value}}",
|
||||
"exportCount_one": "1 Export",
|
||||
"exportCount_other": "{{count}} Exports",
|
||||
"cameraCount_one": "1 Kamera",
|
||||
"cameraCount_other": "{{count}} Kameras",
|
||||
"showMore": "Mehr anzeigen",
|
||||
"showLess": "Weniger Anzeigen",
|
||||
"emptyTitle": "Der Fall ist leer",
|
||||
"emptyDescription": "Fügen Sie vorhandene, nicht kategorisierte Exporte hinzu, um den Fall übersichtlich zu halten.",
|
||||
"emptyDescriptionNoExports": "Es sind noch keine nicht kategorisierten Exporte zum Hinzufügen verfügbar."
|
||||
},
|
||||
"caseEditor": {
|
||||
"createTitle": "Fall erstellen",
|
||||
"editTitle": "Fall bearbeiten",
|
||||
"namePlaceholder": "Fall Name",
|
||||
"descriptionPlaceholder": "Fügen Sie Anmerkungen oder Kontext zu diesem Fall hinzu"
|
||||
},
|
||||
"addExportDialog": {
|
||||
"title": "Export zum {{caseName}} hinzufügen",
|
||||
"searchPlaceholder": "Suche nach nicht kategorisierten Exporten",
|
||||
"empty": "Es wurden keine nicht kategorisierten Exporte gefunden, die dieser Suche entsprechen.",
|
||||
"addButton_one": "1 Export hinzufügen",
|
||||
"addButton_other": "Fügen Sie {{count}} Exporte hinzu",
|
||||
"adding": "Hinzufügen..."
|
||||
},
|
||||
"selected_one": "{{count}} ausgewählt",
|
||||
"selected_other": "{{count}} ausgewählt",
|
||||
"bulkActions": {
|
||||
"addToCase": "Zum Fall hinzufügen",
|
||||
"moveToCase": "Zum Fall wechseln",
|
||||
"removeFromCase": "Aus dem Fall nehmen",
|
||||
"delete": "löschen",
|
||||
"deleteNow": "jetzt löschen"
|
||||
},
|
||||
"bulkDelete": {
|
||||
"title": "Exporte löschen",
|
||||
"desc_one": "Möchten Sie den Export {{count}} wirklich löschen?",
|
||||
"desc_other": "Möchten Sie wirklich {{count}} Exporte löschen?"
|
||||
},
|
||||
"bulkRemoveFromCase": {
|
||||
"title": "Aus dem Fall nehmen",
|
||||
"desc_one": "{{count}}-Export aus diesem Fall entfernen?",
|
||||
"desc_other": "{{count}} Exporte aus diesem Fall entfernen?",
|
||||
"descKeepExports": "Die Exporte werden in die Kategorie „Nicht kategorisiert“ verschoben.",
|
||||
"descDeleteExports": "Exporte werden endgültig gelöscht.",
|
||||
"deleteExports": "Löschen Sie stattdessen Exporte"
|
||||
},
|
||||
"bulkToast": {
|
||||
"success": {
|
||||
"delete": "Exporte erfolgreich gelöscht",
|
||||
"reassign": "Fallzuweisung erfolgreich aktualisiert",
|
||||
"remove": "Exporte erfolgreich aus dem Fall entfernt"
|
||||
},
|
||||
"error": {
|
||||
"deleteFailed": "Fehler beim Löschen der Exporte: {{errorMessage}}",
|
||||
"reassignFailed": "Fehler beim Aktualisieren der Fallzuordnung: {{errorMessage}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +345,10 @@
|
||||
"zone": "Zone",
|
||||
"motion_mask": "Bewegungsmaske",
|
||||
"object_mask": "Objektmaske"
|
||||
},
|
||||
"revertOverride": {
|
||||
"title": "Basis Konfiguration wiederherstellen",
|
||||
"desc": "Dadurch wird die Profilüberschreibung für {{type}}<em>{{name}}</em> aufgehoben und die Grundkonfiguration wiederhergestellt."
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
@@ -507,7 +511,8 @@
|
||||
"title": "Aktiviert",
|
||||
"description": "Ob diese Maske in der Konfigurationsdatei aktiviert ist. Ist sie deaktiviert, kann sie nicht über MQTT aktiviert werden. Deaktivierte Masken werden zur Laufzeit ignoriert."
|
||||
}
|
||||
}
|
||||
},
|
||||
"addDisabledProfile": "Fügen Sie es zuerst der Basiskonfiguration hinzu und überschreiben Sie es dann im Profil"
|
||||
},
|
||||
"debug": {
|
||||
"objectShapeFilterDrawing": {
|
||||
@@ -1647,7 +1652,8 @@
|
||||
"keyDuplicate": "Der Name des Detektors ist bereits vorhanden.",
|
||||
"noSchema": "Es sind keine Detektorschemata verfügbar.",
|
||||
"none": "Es sind keine Detektorinstanzen konfiguriert.",
|
||||
"add": "Detektor hinzufügen"
|
||||
"add": "Detektor hinzufügen",
|
||||
"addCustomKey": "Benutzerdefinierten Schlüssel hinzufügen"
|
||||
},
|
||||
"record": {
|
||||
"title": "Aufnahmeeinstellungen"
|
||||
@@ -1718,7 +1724,16 @@
|
||||
"title": "Einstellungen für Zeitstempel"
|
||||
},
|
||||
"searchPlaceholder": "Suche...",
|
||||
"addCustomLabel": "Benutzerdefiniertes Etikett hinzufügen..."
|
||||
"addCustomLabel": "Benutzerdefiniertes Etikett hinzufügen...",
|
||||
"knownPlates": {
|
||||
"namePlaceholder": "z.B. das Auto der Frau",
|
||||
"platePlaceholder": "Kennzeichen oder regulärer Ausdruck"
|
||||
},
|
||||
"genaiModel": {
|
||||
"placeholder": "Modell auswählen…",
|
||||
"search": "Modell suchen…",
|
||||
"noModels": "Keine Modelle verfügbar"
|
||||
}
|
||||
},
|
||||
"globalConfig": {
|
||||
"title": "Globale Konfiguration",
|
||||
@@ -1876,6 +1891,10 @@
|
||||
},
|
||||
"snapshots": {
|
||||
"detectDisabled": "Die Objekterkennung ist deaktiviert. Es werden keine Momentaufnahmen von verfolgten Objekten erstellt."
|
||||
},
|
||||
"detectors": {
|
||||
"mixedTypes": "Alle Detektoren müssen von gleichem Typ sein, Entferne bestehende Detektoren um einen anderen Typ zu benutzen.",
|
||||
"mixedTypesSuggestion": "Alle Detektoren müssen vom gleichem Typ sein. Entferne bestehende oder wähle {{type}}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,14 @@
|
||||
{}
|
||||
{
|
||||
"version": {
|
||||
"label": "Τρέχουσα έκδοση διαμόρφωσης"
|
||||
},
|
||||
"safe_mode": {
|
||||
"label": "Ασφαλής λειτουργία"
|
||||
},
|
||||
"auth": {
|
||||
"reset_admin_password": {
|
||||
"label": "Επανέφερε κωδικού πρόσβασης για τον διαχειριστή admin",
|
||||
"description": "Άμα είναι αλήθεια, επαναφέρει τον κωδικό πρόσβασης του χρήστη διαχειριστή(admin) κατά την εκκίνηση και εκτύπωση του νέου κωδικού πρόσβασης στα αρχείο καταγραφής(logs)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"description": "Enabled"
|
||||
},
|
||||
"audio": {
|
||||
"label": "Audio events",
|
||||
"label": "Audio detection",
|
||||
"description": "Settings for audio-based event detection for this camera.",
|
||||
"enabled": {
|
||||
"label": "Enable audio detection",
|
||||
@@ -485,6 +485,10 @@
|
||||
"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": "SR-IOV device",
|
||||
"description": "Device identifier used when treating Intel GPUs as SR-IOV to fix GPU stats."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"version_check": {
|
||||
@@ -539,7 +539,7 @@
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"label": "Audio events",
|
||||
"label": "Audio detection",
|
||||
"description": "Settings for audio-based event detection for all cameras; can be overridden per-camera.",
|
||||
"enabled": {
|
||||
"label": "Enable audio detection",
|
||||
@@ -1000,6 +1000,10 @@
|
||||
"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": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user