mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-11 01:02:48 +03:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c9295ffbc | ||
|
|
79177db969 | ||
|
|
e8e6326d37 | ||
|
|
01f3369659 | ||
|
|
f1d1748fb3 | ||
|
|
f084e6acc7 | ||
|
|
a3ea529eb5 | ||
|
|
c53adf1f72 | ||
|
|
528967a783 | ||
|
|
02dd4df3d9 | ||
|
|
fb67d735b6 | ||
|
|
25f8568c66 | ||
|
|
f1c4a9f769 | ||
|
|
28d8f5bbfa | ||
|
|
8b89b79ef5 |
@@ -1,4 +1,4 @@
|
||||
ruff == 0.15.20
|
||||
ruff == 0.16.10
|
||||
|
||||
# types
|
||||
types-peewee == 4.0.*
|
||||
|
||||
@@ -1,66 +1,66 @@
|
||||
aiofiles == 25.1.*
|
||||
click == 8.5.*
|
||||
# FastAPI
|
||||
aiohttp == 3.12.*
|
||||
starlette == 0.47.*
|
||||
aiohttp == 3.14.*
|
||||
starlette == 1.7.*
|
||||
starlette-context == 0.5.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.116.*
|
||||
uvicorn == 0.52.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.142.*
|
||||
uvicorn == 0.54.*
|
||||
slowapi == 0.1.*
|
||||
joserfc == 1.6.*
|
||||
cryptography == 46.0.*
|
||||
joserfc == 1.7.*
|
||||
cryptography == 50.0.*
|
||||
pathvalidate == 3.3.*
|
||||
markupsafe == 3.0.*
|
||||
python-multipart == 0.0.31
|
||||
python-multipart == 0.0.32
|
||||
# Classification Model Training
|
||||
tensorflow == 2.19.* ; platform_machine == 'aarch64'
|
||||
tensorflow-cpu == 2.19.* ; platform_machine == 'x86_64'
|
||||
tensorflow-cpu == 2.21.* ; platform_machine == 'x86_64'
|
||||
# General
|
||||
mypy == 1.6.1
|
||||
onvif-zeep-async == 4.0.*
|
||||
mypy == 2.4.0
|
||||
onvif-zeep-async == 4.3.*
|
||||
paho-mqtt == 2.1.*
|
||||
pandas == 2.2.*
|
||||
peewee == 3.17.*
|
||||
peewee_migrate == 1.14.*
|
||||
psutil == 7.1.*
|
||||
pydantic == 2.10.*
|
||||
pandas == 3.0.*
|
||||
peewee == 4.5.*
|
||||
peewee_migrate == 2.3.*
|
||||
psutil == 7.2.*
|
||||
pydantic == 2.13.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
pytz == 2025.*
|
||||
pyzmq == 27.1.*
|
||||
pytz == 2026.*
|
||||
pyzmq == 27.2.*
|
||||
ruamel.yaml == 0.19.*
|
||||
tzlocal == 5.2
|
||||
requests == 2.33.*
|
||||
types-requests == 2.32.*
|
||||
tzlocal == 5.4.4
|
||||
requests == 2.34.*
|
||||
types-requests == 2.33.*
|
||||
norfair == 2.3.*
|
||||
setproctitle == 1.3.*
|
||||
ws4py == 0.5.*
|
||||
ws4py == 0.6.*
|
||||
unidecode == 1.4.*
|
||||
titlecase == 2.4.*
|
||||
# Image Manipulation
|
||||
numpy == 1.26.*
|
||||
opencv-python-headless == 4.11.0.*
|
||||
opencv-contrib-python == 4.11.0.*
|
||||
scipy == 1.16.*
|
||||
scipy == 1.17.*
|
||||
# OpenVino & ONNX
|
||||
openvino == 2025.4.*
|
||||
onnxruntime == 1.30.*
|
||||
# Embeddings
|
||||
transformers == 4.45.*
|
||||
transformers == 5.19.*
|
||||
# Generative AI
|
||||
google-genai == 1.58.*
|
||||
google-genai == 2.29.*
|
||||
ollama == 0.6.*
|
||||
openai == 1.65.*
|
||||
openai == 3.26.*
|
||||
# push notifications
|
||||
py-vapid == 1.9.4
|
||||
pywebpush == 2.0.*
|
||||
pywebpush == 2.5.*
|
||||
# alpr
|
||||
pyclipper == 1.4.*
|
||||
shapely == 2.0.*
|
||||
rapidfuzz==3.12.*
|
||||
shapely == 2.2.*
|
||||
rapidfuzz==3.14.*
|
||||
# HailoRT
|
||||
argcomplete==3.7.*
|
||||
contextlib2==0.6.*
|
||||
future==0.18.*
|
||||
contextlib2==21.6.*
|
||||
future==1.0.*
|
||||
netaddr==1.3.*
|
||||
netifaces==0.10.*
|
||||
prometheus-client == 0.26.*
|
||||
@@ -71,6 +71,6 @@ tflite_runtime @ https://github.com/feranick/TFlite-builds/releases/download/v2.
|
||||
sherpa-onnx==1.13.*
|
||||
faster-whisper==1.2.*
|
||||
librosa==0.11.*
|
||||
soundfile==0.13.*
|
||||
soundfile==0.14.*
|
||||
# Memory profiling
|
||||
memray == 1.20.*
|
||||
|
||||
@@ -14,4 +14,4 @@ nvidia-nccl-cu12==2.26.2.post1; platform_machine == 'x86_64'
|
||||
nvidia-nvjitlink-cu12==12.8.93; platform_machine == 'x86_64'
|
||||
onnx==1.16.*; platform_machine == 'x86_64'
|
||||
onnxruntime-gpu==1.24.*; platform_machine == 'x86_64'
|
||||
protobuf==3.20.3; platform_machine == 'x86_64'
|
||||
protobuf==7.36.*; platform_machine == 'x86_64'
|
||||
|
||||
@@ -289,7 +289,7 @@ The only field that is valid at the camera level is `enabled`. In particular `mo
|
||||
|
||||
#### GenAI Provider
|
||||
|
||||
Frigate can send audio to a GenAI provider for transcription when that provider has the `transcribe` role. This is useful if you already run a GenAI provider, or if you do not have the CPU/GPU headroom for a local whisper model. Supported providers are **OpenAI**, **Azure OpenAI**, **Gemini**, and **llama.cpp** with an audio-capable model (a dedicated ASR model such as Qwen3-ASR, or a general multimodal model that accepts audio). Ollama is not supported as it has no audio input.
|
||||
Frigate can send audio to a GenAI provider for transcription when that provider has the `transcribe` role. This is useful if you already run a GenAI provider, or if you do not have the CPU/GPU headroom for a local whisper model. See [Provider support](/configuration/genai/genai_config#provider-support) for which providers can serve this role. The model must accept audio: either a dedicated ASR model such as Qwen3-ASR, or a general multimodal model that accepts audio.
|
||||
|
||||
To use a GenAI provider for audio transcription:
|
||||
|
||||
|
||||
@@ -169,10 +169,10 @@ The [Add Camera Wizard](cameras.md#adding-a-camera-with-the-add-camera-wizard) i
|
||||
|
||||
1. Click **Add Camera** in <NavPath path="Settings > Global configuration > Camera management" />.
|
||||
2. Choose **Manual selection** as the stream detection method and select **Reolink** as the camera brand.
|
||||
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower, or an RTSP stream for higher resolution cameras.
|
||||
4. In the validation step, enable **Use stream compatibility mode** for http-flv streams when the wizard recommends it.
|
||||
3. The wizard queries the camera and automatically uses an http-flv stream for cameras 5MP and lower. For higher resolution cameras, it tries the http-flv stream first and falls back to RTSP when the camera does not support it.
|
||||
4. The wizard turns on **Use stream compatibility mode** for the http-flv stream it selects. Enable it for any other http-flv stream you add, such as the sub stream.
|
||||
|
||||
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras where http-flv is recommended, the wizard will show a warning in the validation step.
|
||||
If you use the **Probe camera** method instead, the discovered stream URLs will be RTSP. For Reolink cameras 5MP and lower, where http-flv is recommended, the wizard will show a warning in the validation step.
|
||||
|
||||
The wizard covers standard single-camera setups. For two way talk, cameras connected through a Reolink NVR, or audio transcoding for WebRTC live view, configure the camera manually as shown below.
|
||||
|
||||
|
||||
@@ -43,7 +43,19 @@ genai:
|
||||
|
||||
The examples on this page all use `my_provider`, but the name is arbitrary and is only used to reference the provider elsewhere in the config (for example, `semantic_search.model`).
|
||||
|
||||
Each provider handles one or more **roles**: `chat`, `descriptions`, `embeddings`, and `transcribe`. A provider handles the first three by default; `transcribe` must always be listed explicitly, and is not available on Ollama, which has no audio input. Each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option.
|
||||
Each provider handles one or more **roles**: `chat`, `descriptions`, `embeddings`, and `transcribe`. A provider handles the first three by default; `transcribe` must always be listed explicitly. Each role may be assigned to exactly one provider. Define a single provider if you want it to do everything, or split the roles across several providers using the `roles` option. Not every provider supports every role; see [Provider support](#provider-support).
|
||||
|
||||
### Provider support
|
||||
|
||||
| Provider | Descriptions | Chat | Embeddings | Transcription |
|
||||
| ----------------------------- | :----------: | :--: | :--------: | :-----------: |
|
||||
| llama.cpp (`llamacpp`) | ✅ | ✅ | ✅ | ✅ |
|
||||
| Ollama (`ollama`) | ✅ | ✅ | ✅ | ❌ |
|
||||
| OpenAI (`openai`) | ✅ | ✅ | ❌ | ✅ |
|
||||
| Azure OpenAI (`azure_openai`) | ✅ | ✅ | ❌ | ✅ |
|
||||
| Google Gemini (`gemini`) | ✅ | ✅ | ❌ | ✅ |
|
||||
|
||||
A ✅ means Frigate can use the provider for that feature. The configured model must also support it: a vision model for descriptions and chat, a multimodal embedding model for embeddings (see [Embedding models](#embedding-models)), and an audio-capable model for transcription. Some features also need extra provider setup, covered in each provider's section below. OpenAI-compatible servers use the `openai` provider, so they follow the OpenAI row.
|
||||
|
||||
If the provider you choose requires an API key, you may either directly paste it in your configuration, or store it in an environment variable prefixed with `FRIGATE_`.
|
||||
|
||||
@@ -73,9 +85,10 @@ You must use a vision-capable model with Frigate. The following models are recom
|
||||
|
||||
The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why.
|
||||
|
||||
| Model | Notes |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Must be served by llama.cpp started with `--embeddings` and `--mmproj`. |
|
||||
| Model | Notes |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `embeddinggemma-2` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Strong semantic search accuracy with efficient inference on a small model. |
|
||||
| `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Good performance, large model that requires strong hardware for inference. |
|
||||
|
||||
#### Transcription models
|
||||
|
||||
@@ -152,6 +165,10 @@ genai:
|
||||
|
||||
Frigate queries the llama.cpp server for the model's context size at startup and logs it along with the other detected capabilities. If `context_size` is set in `provider_options`, that value is always used instead, even when the server reports its own.
|
||||
|
||||
#### Embeddings
|
||||
|
||||
To serve the `embeddings` role for [Semantic Search](/configuration/semantic_search#genai-provider), start the llama.cpp server with `--embeddings`, plus `--mmproj` for image support. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for details.
|
||||
|
||||
### Ollama
|
||||
|
||||
[Ollama](https://ollama.com/) allows you to self-host large language models and keep everything running locally. It is highly recommended to host this server on a machine with an Nvidia graphics card, or on a Apple silicon Mac for best performance.
|
||||
@@ -197,6 +214,10 @@ genai:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
#### Embeddings
|
||||
|
||||
Ollama can serve the `embeddings` role for [Semantic Search](/configuration/semantic_search#genai-provider). Embedding images requires Ollama 0.40.1 or newer and an embedding model with a vision encoder, such as `embeddinggemma-2:440m`. For a saved provider, the UI hides the role unless Ollama reports its model as an embedding model. Use a separate provider entry for the embedding model rather than adding the role to a vision chat model.
|
||||
|
||||
### OpenAI-Compatible
|
||||
|
||||
Frigate supports any provider that implements the OpenAI API standard. This includes self-hosted solutions like [vLLM](https://docs.vllm.ai/), [LocalAI](https://localai.io/), and other OpenAI-compatible servers.
|
||||
|
||||
@@ -133,13 +133,12 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
|
||||
|
||||
### GenAI Provider
|
||||
|
||||
Frigate can use a GenAI provider for semantic search embeddings when that provider has the `embeddings` role. Currently, only **llama.cpp** supports multimodal embeddings (both text and images).
|
||||
Frigate can use a GenAI provider for semantic search embeddings when that provider has the `embeddings` role. See [Provider support](/configuration/genai/genai_config#provider-support) for which providers can serve this role.
|
||||
|
||||
To use llama.cpp for semantic search:
|
||||
To use a GenAI provider for semantic search:
|
||||
|
||||
1. Configure a GenAI provider with `embeddings` in its `roles`.
|
||||
1. Configure a GenAI provider with `embeddings` in its `roles`, using a multimodal embedding model (both text and images). See [Embedding models](/configuration/genai/genai_config#embedding-models) for recommendations, and your provider's section of the [GenAI docs](/configuration/genai/genai_config) for any extra setup it needs.
|
||||
2. Set the semantic search model to the GenAI config key (e.g. `default`).
|
||||
3. Start the llama.cpp server with `--embeddings` and `--mmproj` for image support.
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
@@ -174,8 +173,6 @@ semantic_search:
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
The llama.cpp server must be started with `--embeddings` for the embeddings API, and a multi-modal embeddings model. See the [llama.cpp server documentation](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) for details.
|
||||
|
||||
:::note
|
||||
|
||||
Switching between Jina models and a GenAI provider requires reindexing. Embeddings from different backends are incompatible.
|
||||
|
||||
@@ -189,7 +189,7 @@ Frigate is able to utilize an Nvidia GPU which supports the 12.x series of CUDA
|
||||
|
||||
#### Minimum Hardware Support
|
||||
|
||||
12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. Also the GPU must support a Compute Capability of `5.0` or greater. This generally correlates to a Maxwell-era GPU or newer, check the NVIDIA GPU Compute Capability table linked below.
|
||||
12.x series of CUDA libraries are used which have minor version compatibility. The minimum driver version on the host system must be `>=545`. The oldest supported GPU generation for object detection is Pascal (Compute Capability 6.0 or greater). Older generations such as Maxwell are not supported but can still be used by following a [community workaround](https://github.com/blakeblackshear/frigate/discussions/20088). For other GPUs, check the NVIDIA GPU Compute Capability table linked below.
|
||||
|
||||
Make sure your host system has the [nvidia-container-runtime](https://docs.docker.com/config/containers/resource_constraints/#access-an-nvidia-gpu) installed to pass through the GPU to the container and the host system has a compatible driver installed for your GPU.
|
||||
|
||||
|
||||
@@ -53,14 +53,6 @@ go2rtc:
|
||||
|
||||
Point the camera's inputs at the restream as described in the [restream docs](/configuration/restream.md), and swap `detect -> width` and `detect -> height` to match the rotated resolution.
|
||||
|
||||
### Can I add a privacy mask to hide part of my camera's view?
|
||||
|
||||
Frigate does not have privacy masks. [Motion masks and object filter masks](../configuration/masks.md) only affect detection, they don't hide anything in live view, recordings, or snapshots.
|
||||
|
||||
Privacy masks are best configured in the camera's firmware settings so the area is blacked out before the video ever leaves the camera and no extra processing is needed. Check there first.
|
||||
|
||||
If your camera does not support privacy masks, there is no efficient alternative. Frigate copies the camera's video into recordings and live view without decoding it, so part of the image can't be hidden without transcoding (re-encoding) the stream. This can be done with a custom ffmpeg filter in go2rtc, but it is not recommended. Every masked camera needs a continuous re-encode, which significantly increases CPU usage, especially for high resolution streams.
|
||||
|
||||
### My mjpeg stream or snapshots look green and crazy
|
||||
|
||||
This almost always means that the width/height defined for your camera are not correct. Double check the resolution with VLC or another player. Also make sure you don't have the width and height values backwards.
|
||||
|
||||
Generated
+24
-15
@@ -7030,9 +7030,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -7698,14 +7698,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
|
||||
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"destroy": "1.2.0",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
@@ -7713,6 +7714,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/bytes": {
|
||||
@@ -17448,9 +17453,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
@@ -17458,6 +17463,10 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr/node_modules/ipaddr.js": {
|
||||
@@ -19229,9 +19238,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
|
||||
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
|
||||
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -19513,9 +19522,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
||||
Vendored
+31
-5
@@ -1088,6 +1088,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1131,6 +1132,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1285,6 +1287,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1644,6 +1647,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1691,6 +1695,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1738,6 +1743,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1779,6 +1785,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -1862,6 +1869,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -3483,6 +3491,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Body
|
||||
responses:
|
||||
'200':
|
||||
@@ -5652,6 +5661,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Response Create Trigger Embedding Trigger Embedding Post
|
||||
'422':
|
||||
description: Validation Error
|
||||
@@ -5700,6 +5710,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Response Update Trigger Embedding Trigger Embedding
|
||||
Camera Name Name Put
|
||||
'422':
|
||||
@@ -5742,6 +5753,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Response Delete Trigger Embedding Trigger Embedding
|
||||
Camera Name Name Delete
|
||||
'422':
|
||||
@@ -5778,6 +5790,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Response Get Triggers Status Triggers Status Camera Name
|
||||
Get
|
||||
'422':
|
||||
@@ -7574,6 +7587,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
title: Response No Recordings Recordings Unavailable Get
|
||||
'422':
|
||||
description: Validation Error
|
||||
@@ -7777,7 +7791,8 @@ components:
|
||||
title: Update Topic
|
||||
config_data:
|
||||
anyOf:
|
||||
- type: object
|
||||
- additionalProperties: true
|
||||
type: object
|
||||
- type: 'null'
|
||||
title: Config Data
|
||||
skip_save:
|
||||
@@ -8017,7 +8032,7 @@ components:
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
contentMediaType: application/octet-stream
|
||||
title: File
|
||||
type: object
|
||||
required:
|
||||
@@ -8027,7 +8042,7 @@ components:
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
contentMediaType: application/octet-stream
|
||||
title: File
|
||||
type: object
|
||||
required:
|
||||
@@ -8125,6 +8140,7 @@ components:
|
||||
tool_calls:
|
||||
anyOf:
|
||||
- items:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
type: array
|
||||
- type: 'null'
|
||||
@@ -8420,6 +8436,7 @@ components:
|
||||
- type: 'null'
|
||||
title: Model Type
|
||||
data:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
title: Data
|
||||
type: object
|
||||
@@ -8491,7 +8508,8 @@ components:
|
||||
default: true
|
||||
draw:
|
||||
anyOf:
|
||||
- type: object
|
||||
- additionalProperties: true
|
||||
type: object
|
||||
- type: 'null'
|
||||
title: Draw
|
||||
default: {}
|
||||
@@ -8734,7 +8752,8 @@ components:
|
||||
description: Error message for failed jobs
|
||||
results:
|
||||
anyOf:
|
||||
- type: object
|
||||
- additionalProperties: true
|
||||
type: object
|
||||
- type: 'null'
|
||||
title: Results
|
||||
description: Result metadata for completed jobs
|
||||
@@ -8984,6 +9003,7 @@ components:
|
||||
- type: 'null'
|
||||
title: Base Url
|
||||
provider_options:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
title: Provider Options
|
||||
type: object
|
||||
@@ -9524,6 +9544,7 @@ components:
|
||||
type: string
|
||||
title: Tool Name
|
||||
arguments:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
title: Arguments
|
||||
type: object
|
||||
@@ -9601,6 +9622,11 @@ components:
|
||||
type:
|
||||
type: string
|
||||
title: Error Type
|
||||
input:
|
||||
title: Input
|
||||
ctx:
|
||||
type: object
|
||||
title: Context
|
||||
type: object
|
||||
required:
|
||||
- loc
|
||||
|
||||
@@ -702,7 +702,7 @@ def motion_activity(
|
||||
df = df[df["camera"] != ""]
|
||||
|
||||
# change types for output
|
||||
df.index = df.index.astype(int) // (10**9)
|
||||
df.index = df.index.as_unit("s").astype(int)
|
||||
normalized = df.reset_index().to_dict("records")
|
||||
return JSONResponse(content=normalized)
|
||||
|
||||
|
||||
+3
-3
@@ -12,8 +12,8 @@ from pathlib import Path
|
||||
|
||||
import psutil
|
||||
import uvicorn
|
||||
from peewee import SqliteDatabase
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
|
||||
from frigate.api.auth import hash_password
|
||||
from frigate.api.fastapi_app import create_fastapi_app
|
||||
@@ -186,7 +186,7 @@ class FrigateApp:
|
||||
self.timeline_queue: Queue = mp.Queue()
|
||||
|
||||
def init_database(self) -> None:
|
||||
def vacuum_db(db: SqliteExtDatabase) -> None:
|
||||
def vacuum_db(db: SqliteDatabase) -> None:
|
||||
logger.info("Running database vacuum")
|
||||
db.execute_sql("VACUUM;")
|
||||
|
||||
@@ -197,7 +197,7 @@ class FrigateApp:
|
||||
logger.error("Unable to write to /config to save DB state")
|
||||
|
||||
# Migrate DB schema
|
||||
migrate_db = SqliteExtDatabase(self.config.database.path)
|
||||
migrate_db = SqliteDatabase(self.config.database.path)
|
||||
|
||||
# Run migrations
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
|
||||
@@ -54,12 +54,12 @@ class PTZMetrics:
|
||||
reset: Event
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.start_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.stop_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.frame_time = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.zoom_level = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.max_zoom = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.min_zoom = mp.Value("d", 0) # type: ignore[assignment]
|
||||
self.start_time = mp.Value("d", 0)
|
||||
self.stop_time = mp.Value("d", 0)
|
||||
self.frame_time = mp.Value("d", 0)
|
||||
self.zoom_level = mp.Value("d", 0)
|
||||
self.max_zoom = mp.Value("d", 0)
|
||||
self.min_zoom = mp.Value("d", 0)
|
||||
|
||||
self.motor_stopped = mp.Event()
|
||||
self.reset = mp.Event()
|
||||
|
||||
+29
-6
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import queue
|
||||
import selectors
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -57,6 +58,11 @@ class MqttClient(Communicator):
|
||||
self._next_connect_time = 0.0
|
||||
self._last_on_connect_dispatch = 0.0
|
||||
|
||||
# lets other threads interrupt the worker's socket wait
|
||||
self._wake_recv, self._wake_send = socket.socketpair()
|
||||
self._wake_recv.setblocking(False)
|
||||
self._wake_send.setblocking(False)
|
||||
|
||||
def subscribe(self, receiver: Callable) -> None:
|
||||
"""Wrapper for allowing dispatcher to subscribe."""
|
||||
self._dispatcher = receiver
|
||||
@@ -86,6 +92,7 @@ class MqttClient(Communicator):
|
||||
return
|
||||
|
||||
self._publish_queue.put(QueuedPublish(full_topic, payload, retain))
|
||||
self._wake_worker()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._worker is None:
|
||||
@@ -101,9 +108,11 @@ class MqttClient(Communicator):
|
||||
publish_done,
|
||||
)
|
||||
)
|
||||
self._wake_worker()
|
||||
publish_done.wait(MQTT_SHUTDOWN_FLUSH_TIMEOUT)
|
||||
|
||||
self._stop_event.set()
|
||||
self._wake_worker()
|
||||
|
||||
if self.client is not None:
|
||||
try:
|
||||
@@ -369,11 +378,17 @@ class MqttClient(Communicator):
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _wake_worker(self) -> None:
|
||||
try:
|
||||
self._wake_send.send(b"\0")
|
||||
except BlockingIOError:
|
||||
# the buffer is full, so a wake is already pending
|
||||
pass
|
||||
|
||||
def _loop_client(self, timeout: float) -> int:
|
||||
"""Drive Paho without select()'s limit on socket file descriptors."""
|
||||
assert self.client is not None
|
||||
client = self.client
|
||||
if client is None:
|
||||
return mqtt.MQTT_ERR_NO_CONN
|
||||
sock = client.socket()
|
||||
if sock is None:
|
||||
return mqtt.MQTT_ERR_NO_CONN
|
||||
@@ -385,11 +400,19 @@ class MqttClient(Communicator):
|
||||
pending = hasattr(sock, "pending") and sock.pending() > 0
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(sock, events)
|
||||
ready = selector.select(0.0 if pending else timeout)
|
||||
selector.register(self._wake_recv, selectors.EVENT_READ)
|
||||
ready = {
|
||||
key.fileobj: mask
|
||||
for key, mask in selector.select(0.0 if pending else timeout)
|
||||
}
|
||||
|
||||
ready_events = 0
|
||||
for _, mask in ready:
|
||||
ready_events |= mask
|
||||
if self._wake_recv in ready:
|
||||
try:
|
||||
self._wake_recv.recv(4096)
|
||||
except BlockingIOError:
|
||||
pass
|
||||
|
||||
ready_events = ready.get(sock, 0)
|
||||
if pending or ready_events & selectors.EVENT_READ:
|
||||
result = client.loop_read()
|
||||
if result != mqtt.MQTT_ERR_SUCCESS or client.socket() is None:
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
@@ -72,7 +72,7 @@ class WebPushClient(Communicator):
|
||||
# Pull keys from PEM or generate if they do not exist
|
||||
self.vapid = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem"))
|
||||
|
||||
users: list[dict[str, Any]] = (
|
||||
users: Iterator[dict[str, Any]] = (
|
||||
User.select(User.username, User.notification_tokens).dicts().iterator()
|
||||
)
|
||||
for user in users:
|
||||
|
||||
@@ -580,8 +580,8 @@ class LicensePlateProcessingMixin:
|
||||
boxes = []
|
||||
scores = []
|
||||
|
||||
for index in range(len(contours)): # type: ignore[arg-type]
|
||||
contour = contours[index] # type: ignore[index]
|
||||
for index in range(len(contours)):
|
||||
contour = contours[index]
|
||||
|
||||
# get minimum bounding box (rotated rectangle) around the contour and the smallest side length.
|
||||
points, sside = self._get_min_boxes(contour)
|
||||
@@ -1222,7 +1222,7 @@ class LicensePlateProcessingMixin:
|
||||
"""Look for license plates in image."""
|
||||
self.metrics.alpr_pps.value = self.plates_rec_second.eps()
|
||||
self.metrics.yolov9_lpr_pps.value = self.plates_det_second.eps()
|
||||
camera = obj_data if dedicated_lpr else obj_data["camera"]
|
||||
camera: str = obj_data if dedicated_lpr else obj_data["camera"] # type: ignore[assignment]
|
||||
current_time = int(datetime.datetime.now().timestamp())
|
||||
debug_frame_id = int(datetime.datetime.now().timestamp() * 1000)
|
||||
|
||||
|
||||
@@ -365,7 +365,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
|
||||
img = cv2.imread(current_file)
|
||||
|
||||
if img is None:
|
||||
return { # type: ignore[unreachable]
|
||||
return {
|
||||
"message": "Invalid image file.",
|
||||
"success": False,
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import warnings
|
||||
|
||||
from transformers import AutoFeatureExtractor, AutoTokenizer
|
||||
from transformers import AutoTokenizer, CLIPImageProcessor
|
||||
from transformers.utils.logging import disable_progress_bar
|
||||
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
@@ -21,12 +21,6 @@ from frigate.util.downloader import ModelDownloader
|
||||
|
||||
from .base_embedding import BaseEmbedding
|
||||
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
category=FutureWarning,
|
||||
message="The class CLIPFeatureExtractor is deprecated",
|
||||
)
|
||||
|
||||
# disables the progress bar for downloading tokenizers and feature extractors
|
||||
disable_progress_bar()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -58,6 +52,13 @@ class JinaV1TextEmbedding(BaseEmbedding):
|
||||
self._lock = threading.Lock()
|
||||
files_names = list(self.download_urls.keys()) + [self.tokenizer_file]
|
||||
|
||||
# an interrupted download leaves the hub cache without the saved tokenizer
|
||||
tokenizer_path = os.path.join(self.download_path, self.tokenizer_file)
|
||||
if os.path.isdir(tokenizer_path) and not os.path.exists(
|
||||
os.path.join(tokenizer_path, "tokenizer_config.json")
|
||||
):
|
||||
shutil.rmtree(tokenizer_path)
|
||||
|
||||
if not all(
|
||||
os.path.exists(os.path.join(self.download_path, n)) for n in files_names
|
||||
):
|
||||
@@ -92,7 +93,7 @@ class JinaV1TextEmbedding(BaseEmbedding):
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(
|
||||
self.model_name,
|
||||
trust_remote_code=True,
|
||||
trust_remote_code=False,
|
||||
cache_dir=f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer",
|
||||
clean_up_tokenization_spaces=True,
|
||||
)
|
||||
@@ -123,9 +124,8 @@ class JinaV1TextEmbedding(BaseEmbedding):
|
||||
f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer"
|
||||
)
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(
|
||||
self.model_name,
|
||||
cache_dir=tokenizer_path,
|
||||
trust_remote_code=True,
|
||||
tokenizer_path,
|
||||
trust_remote_code=False,
|
||||
clean_up_tokenization_spaces=True,
|
||||
)
|
||||
|
||||
@@ -209,7 +209,7 @@ class JinaV1ImageEmbedding(BaseEmbedding):
|
||||
if self.downloader:
|
||||
self.downloader.wait_for_download()
|
||||
|
||||
self.feature_extractor = AutoFeatureExtractor.from_pretrained(
|
||||
self.feature_extractor = CLIPImageProcessor.from_pretrained(
|
||||
f"{MODEL_CACHE_DIR}/{self.model_name}",
|
||||
)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
@@ -60,6 +61,14 @@ class JinaV2Embedding(BaseEmbedding):
|
||||
|
||||
# download the model and tokenizer
|
||||
files_names = list(self.download_urls.keys()) + [self.tokenizer_file]
|
||||
|
||||
# an interrupted download leaves the hub cache without the saved tokenizer
|
||||
tokenizer_path = os.path.join(self.download_path, self.tokenizer_file)
|
||||
if os.path.isdir(tokenizer_path) and not os.path.exists(
|
||||
os.path.join(tokenizer_path, "tokenizer_config.json")
|
||||
):
|
||||
shutil.rmtree(tokenizer_path)
|
||||
|
||||
if not all(
|
||||
os.path.exists(os.path.join(self.download_path, n)) for n in files_names
|
||||
):
|
||||
@@ -97,7 +106,7 @@ class JinaV2Embedding(BaseEmbedding):
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(
|
||||
self.model_name,
|
||||
trust_remote_code=True,
|
||||
trust_remote_code=False,
|
||||
cache_dir=os.path.join(
|
||||
MODEL_CACHE_DIR, self.model_name, "tokenizer"
|
||||
),
|
||||
@@ -129,9 +138,8 @@ class JinaV2Embedding(BaseEmbedding):
|
||||
f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer"
|
||||
)
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(
|
||||
self.model_name,
|
||||
cache_dir=tokenizer_path,
|
||||
trust_remote_code=True,
|
||||
tokenizer_path,
|
||||
trust_remote_code=False,
|
||||
clean_up_tokenization_spaces=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ class EventCleanup(threading.Thread):
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events: list[Event] = (
|
||||
expired_events: list[Event] = list(
|
||||
Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
@@ -97,7 +97,6 @@ class EventCleanup(threading.Thread):
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
expired_events = list(expired_events)
|
||||
logger.debug(f"{len(expired_events)} events can be expired")
|
||||
|
||||
# delete the media from disk
|
||||
@@ -159,7 +158,7 @@ class EventCleanup(threading.Thread):
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events = (
|
||||
camera_events = (
|
||||
Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
@@ -178,7 +177,7 @@ class EventCleanup(threading.Thread):
|
||||
# delete the grabbed clips from disk
|
||||
# only snapshots are stored in /clips
|
||||
# so no need to delete mp4 files
|
||||
for event in expired_events:
|
||||
for event in camera_events:
|
||||
events_to_update.append(str(event.id))
|
||||
deleted = delete_event_snapshot(event)
|
||||
|
||||
@@ -212,7 +211,7 @@ class EventCleanup(threading.Thread):
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events: list[Event] = (
|
||||
expired_events: list[Event] = list(
|
||||
Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
@@ -225,7 +224,6 @@ class EventCleanup(threading.Thread):
|
||||
.namedtuples()
|
||||
.iterator()
|
||||
)
|
||||
expired_events = list(expired_events)
|
||||
logger.debug(f"{len(expired_events)} events can be expired")
|
||||
# delete the media from disk
|
||||
for expired in expired_events:
|
||||
@@ -235,7 +233,7 @@ class EventCleanup(threading.Thread):
|
||||
try:
|
||||
media_path.unlink(missing_ok=True)
|
||||
if file_extension == "jpg":
|
||||
media_path = Path(
|
||||
media_path = Path( # type: ignore[unreachable]
|
||||
f"{os.path.join(CLIPS_DIR, media_name)}-clean.webp"
|
||||
)
|
||||
media_path.unlink(missing_ok=True)
|
||||
@@ -289,7 +287,7 @@ class EventCleanup(threading.Thread):
|
||||
now - datetime.timedelta(days=camera.record.effective_detection_days)
|
||||
).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events = (
|
||||
camera_events = (
|
||||
Event.select(
|
||||
Event.id,
|
||||
Event.camera,
|
||||
@@ -316,7 +314,7 @@ class EventCleanup(threading.Thread):
|
||||
# delete the grabbed clips from disk
|
||||
# only snapshots are stored in /clips
|
||||
# so no need to delete mp4 files
|
||||
for event in expired_events:
|
||||
for event in camera_events:
|
||||
events_to_update.append(event.id)
|
||||
|
||||
# update the clips attribute for the db entry
|
||||
|
||||
@@ -94,7 +94,7 @@ class EventProcessor(threading.Thread):
|
||||
if update == None:
|
||||
continue
|
||||
|
||||
source_type, event_type, camera, _, event_data = update # type: ignore[misc]
|
||||
source_type, event_type, camera, _, event_data = update
|
||||
|
||||
logger.debug(
|
||||
f"Event received: {source_type} {event_type} {camera} {event_data['id']}"
|
||||
|
||||
@@ -22,6 +22,7 @@ from frigate.genai.prompts import (
|
||||
build_review_description_response_format,
|
||||
build_review_summary_prompt,
|
||||
)
|
||||
from frigate.genai.utils import synthetic_jpeg
|
||||
from frigate.models import Event
|
||||
from frigate.util.builtin import has_non_finite_number
|
||||
|
||||
@@ -37,7 +38,7 @@ __all__ = [
|
||||
"register_genai_provider",
|
||||
]
|
||||
|
||||
PROVIDERS = {}
|
||||
PROVIDERS: dict[GenAIProviderEnum, type["GenAIClient"]] = {}
|
||||
|
||||
|
||||
def register_genai_provider(key: GenAIProviderEnum) -> Callable:
|
||||
@@ -66,6 +67,8 @@ class GenAIClient:
|
||||
self.genai_config: GenAIConfig = genai_config
|
||||
self.timeout = timeout
|
||||
self.validate_model = validate_model
|
||||
self._image_token_cache: dict[tuple[int, int], int] = {}
|
||||
self._text_baseline_tokens: int | None = None
|
||||
self.provider = self._init_provider()
|
||||
self._last_init_attempt = time.monotonic()
|
||||
|
||||
@@ -372,10 +375,63 @@ class GenAIClient:
|
||||
def estimate_image_tokens(self, width: int, height: int) -> float:
|
||||
"""Estimate prompt tokens consumed by a single image of the given dimensions.
|
||||
|
||||
Default heuristic: ~1 token per 1250 pixels. Providers that can measure or
|
||||
know their model's exact image-token cost should override.
|
||||
Providers that implement ``_count_prompt_tokens`` are probed for the
|
||||
model's real cost: the same minimal prompt is counted with and without a
|
||||
synthetic image, and the difference is cached per (width, height) since
|
||||
image tokenization depends only on the dimensions and the loaded model.
|
||||
Otherwise, or if probing fails, falls back to ~1 token per 1250 pixels.
|
||||
"""
|
||||
return (width * height) / 1250
|
||||
heuristic = (width * height) / 1250
|
||||
|
||||
if self.provider is None:
|
||||
return heuristic
|
||||
|
||||
cached = self._image_token_cache.get((width, height))
|
||||
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
if self._text_baseline_tokens is None:
|
||||
self._text_baseline_tokens = self._count_prompt_tokens(None)
|
||||
|
||||
if self._text_baseline_tokens is None:
|
||||
return heuristic
|
||||
|
||||
with_image = self._count_prompt_tokens(synthetic_jpeg(width, height))
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"%s image-token probe failed for %dx%d (%s); using heuristic",
|
||||
self.__class__.__name__,
|
||||
width,
|
||||
height,
|
||||
e,
|
||||
)
|
||||
return heuristic
|
||||
|
||||
if with_image is None:
|
||||
return heuristic
|
||||
|
||||
tokens = max(1, with_image - self._text_baseline_tokens)
|
||||
self._image_token_cache[(width, height)] = tokens
|
||||
logger.debug(
|
||||
"%s model '%s' uses ~%d tokens for %dx%d images",
|
||||
self.__class__.__name__,
|
||||
self.genai_config.model,
|
||||
tokens,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
return tokens
|
||||
|
||||
def _count_prompt_tokens(self, image: bytes | None) -> int | None:
|
||||
"""Prompt tokens the provider reports for a minimal "." request, with
|
||||
``image`` attached when given.
|
||||
|
||||
Return None when the provider cannot report prompt tokens; raise on
|
||||
request failures. Used by estimate_image_tokens.
|
||||
"""
|
||||
return None
|
||||
|
||||
def embed(
|
||||
self,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""llama.cpp Provider for Frigate AI."""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
@@ -10,11 +9,14 @@ from typing import Any, cast
|
||||
import httpx
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai.utils import interleave_images, parse_tool_calls_from_message
|
||||
from frigate.genai.utils import (
|
||||
interleave_images,
|
||||
parse_tool_calls_from_message,
|
||||
to_jpeg,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,20 +78,6 @@ def _parse_launch_arg(args: list[str], flag: str) -> str | None:
|
||||
return args[idx + 1]
|
||||
|
||||
|
||||
def _to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||
"""Convert image bytes to JPEG. llama.cpp/STB does not support WebP."""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(img_bytes))
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB") # type: ignore[assignment]
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
return buf.getvalue()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to convert image to JPEG: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@register_genai_provider(GenAIProviderEnum.llamacpp)
|
||||
class LlamaCppClient(GenAIClient):
|
||||
"""Generative AI client for Frigate using llama.cpp server."""
|
||||
@@ -101,8 +89,6 @@ class LlamaCppClient(GenAIClient):
|
||||
_supports_audio: bool
|
||||
_supports_tools: bool
|
||||
_supports_reasoning: bool
|
||||
_image_token_cache: dict[tuple[int, int], int]
|
||||
_text_baseline_tokens: int | None
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
@@ -156,8 +142,6 @@ class LlamaCppClient(GenAIClient):
|
||||
self._supports_audio = False
|
||||
self._supports_tools = False
|
||||
self._supports_reasoning = False
|
||||
self._image_token_cache = {}
|
||||
self._text_baseline_tokens = None
|
||||
|
||||
base_url = (
|
||||
self.genai_config.base_url.rstrip("/")
|
||||
@@ -601,78 +585,23 @@ class LlamaCppClient(GenAIClient):
|
||||
return self._context_size
|
||||
return 4096
|
||||
|
||||
def estimate_image_tokens(self, width: int, height: int) -> float:
|
||||
"""Probe the llama.cpp server to learn the model's image-token cost at the
|
||||
requested dimensions.
|
||||
|
||||
llama.cpp's image tokenization is a deterministic function of dimensions and
|
||||
the loaded mmproj, so the result is cached per (width, height) for the
|
||||
lifetime of the process. Falls back to the base pixel heuristic if the
|
||||
server is unreachable or the response is malformed.
|
||||
"""
|
||||
if self.provider is None:
|
||||
return super().estimate_image_tokens(width, height)
|
||||
|
||||
cached = self._image_token_cache.get((width, height))
|
||||
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
baseline = self._probe_baseline_tokens()
|
||||
with_image = self._probe_image_prompt_tokens(width, height)
|
||||
tokens = max(1, with_image - baseline)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"llama.cpp image-token probe failed for %dx%d (%s); using heuristic",
|
||||
width,
|
||||
height,
|
||||
e,
|
||||
)
|
||||
return super().estimate_image_tokens(width, height)
|
||||
|
||||
self._image_token_cache[(width, height)] = tokens
|
||||
logger.debug(
|
||||
"llama.cpp model '%s' uses ~%d tokens for %dx%d images",
|
||||
self.genai_config.model,
|
||||
tokens,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
return tokens
|
||||
|
||||
def _probe_baseline_tokens(self) -> int:
|
||||
"""Return prompt_tokens for a minimal text-only request. Cached after first call."""
|
||||
if self._text_baseline_tokens is not None:
|
||||
return self._text_baseline_tokens
|
||||
|
||||
self._text_baseline_tokens = self._probe_prompt_tokens(
|
||||
[{"type": "text", "text": "."}]
|
||||
)
|
||||
return self._text_baseline_tokens
|
||||
|
||||
def _probe_image_prompt_tokens(self, width: int, height: int) -> int:
|
||||
"""Return prompt_tokens for a single synthetic image plus minimal text."""
|
||||
img = Image.new("RGB", (width, height), (128, 128, 128))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=60)
|
||||
encoded = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
return self._probe_prompt_tokens(
|
||||
[
|
||||
{"type": "text", "text": "."},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
def _probe_prompt_tokens(self, content: list[dict[str, Any]]) -> int:
|
||||
def _count_prompt_tokens(self, image: bytes | None) -> int | None:
|
||||
"""POST a 1-token chat completion and return reported prompt_tokens.
|
||||
|
||||
Uses a generous timeout to absorb a cold model load on the first probe
|
||||
when the server lazily loads models on demand (e.g. llama-swap).
|
||||
"""
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": "."}]
|
||||
|
||||
if image is not None:
|
||||
encoded = base64.b64encode(image).decode("utf-8")
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
|
||||
}
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": self.genai_config.model,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
@@ -807,7 +736,7 @@ class LlamaCppClient(GenAIClient):
|
||||
|
||||
for img in images:
|
||||
# llama.cpp uses STB which does not support WebP; convert to JPEG
|
||||
jpeg_bytes = _to_jpeg(img)
|
||||
jpeg_bytes = to_jpeg(img)
|
||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||
encoded = base64.b64encode(to_encode).decode("utf-8")
|
||||
# The trailing newline keeps tokenization identical to the older
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from httpx import RemoteProtocolError, TimeoutException
|
||||
from ollama import AsyncClient as OllamaAsyncClient
|
||||
from ollama import Client as ApiClient
|
||||
@@ -14,7 +15,11 @@ from ollama import ResponseError
|
||||
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.genai import GenAIClient, register_genai_provider
|
||||
from frigate.genai.utils import interleave_images, parse_tool_calls_from_message
|
||||
from frigate.genai.utils import (
|
||||
interleave_images,
|
||||
parse_tool_calls_from_message,
|
||||
to_jpeg,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -124,23 +129,39 @@ class OllamaClient(GenAIClient):
|
||||
|
||||
provider: ApiClient | None
|
||||
provider_options: dict[str, Any]
|
||||
_capabilities_cache: list[str] | None = None
|
||||
_supports_thinking_cache: bool | None = None
|
||||
|
||||
def _model_capabilities(self) -> list[str] | None:
|
||||
"""Capabilities Ollama reports for the configured model, or None when
|
||||
they could not be fetched. Only successful lookups are cached."""
|
||||
if self._capabilities_cache is not None:
|
||||
return self._capabilities_cache
|
||||
if self.provider is None:
|
||||
return None
|
||||
try:
|
||||
response = self.provider.show(self.genai_config.model)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to query Ollama model capabilities: %s", e)
|
||||
return None
|
||||
self._capabilities_cache = list(response.get("capabilities") or [])
|
||||
return self._capabilities_cache
|
||||
|
||||
@property
|
||||
def supports_toggleable_thinking(self) -> bool:
|
||||
if self._supports_thinking_cache is not None:
|
||||
return self._supports_thinking_cache
|
||||
if self.provider is None:
|
||||
capabilities = self._model_capabilities()
|
||||
if capabilities is None:
|
||||
return False
|
||||
try:
|
||||
response = self.provider.show(self.genai_config.model)
|
||||
capabilities = response.get("capabilities") or []
|
||||
self._supports_thinking_cache = "thinking" in capabilities
|
||||
except Exception as e:
|
||||
logger.debug("Failed to query Ollama model capabilities: %s", e)
|
||||
self._supports_thinking_cache = False
|
||||
self._supports_thinking_cache = "thinking" in capabilities
|
||||
return self._supports_thinking_cache
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
"""Whether Ollama reports the configured model as an embedding model."""
|
||||
return "embedding" in (self._model_capabilities() or [])
|
||||
|
||||
def _auth_headers(self) -> dict | None:
|
||||
if self.genai_config.api_key:
|
||||
return {"Authorization": "Bearer " + self.genai_config.api_key}
|
||||
@@ -322,6 +343,93 @@ class OllamaClient(GenAIClient):
|
||||
self.genai_config.provider_options.get("options", {}).get("num_ctx", 4096)
|
||||
)
|
||||
|
||||
def _count_prompt_tokens(self, image: bytes | None) -> int | None:
|
||||
"""Send a 1-token chat request and return Ollama's prompt_eval_count.
|
||||
|
||||
Reuses the description request options so the probe runs with the same
|
||||
num_ctx; a different value would make Ollama reload the model.
|
||||
"""
|
||||
if self.provider is None:
|
||||
return None
|
||||
|
||||
message: dict[str, Any] = {"role": "user", "content": "."}
|
||||
|
||||
if image is not None:
|
||||
message["images"] = [image]
|
||||
|
||||
request_params = self._build_request_params(
|
||||
[message], None, None, enable_thinking=False
|
||||
)
|
||||
request_params["options"] = {
|
||||
**(request_params.get("options") or {}),
|
||||
"num_predict": 1,
|
||||
}
|
||||
response = self.provider.chat(**request_params)
|
||||
count = response.get("prompt_eval_count")
|
||||
return int(count) if count is not None else None
|
||||
|
||||
def embed(
|
||||
self,
|
||||
texts: list[str] | None = None,
|
||||
images: list[bytes] | None = None,
|
||||
) -> list[np.ndarray]:
|
||||
"""Generate embeddings via Ollama's /api/embed endpoint.
|
||||
|
||||
Each text is a plain string in `input` and each image is an
|
||||
``{"image": <base64>}`` item. Image input requires Ollama 0.40.1 or
|
||||
newer and a model with a vision encoder (e.g. embeddinggemma-2:440m).
|
||||
"""
|
||||
if self.provider is None:
|
||||
logger.warning(
|
||||
"Ollama provider has not been initialized. Check your Ollama configuration."
|
||||
)
|
||||
return []
|
||||
|
||||
texts = texts or []
|
||||
images = images or []
|
||||
if not texts and not images:
|
||||
return []
|
||||
|
||||
inputs: list[str | dict[str, str]] = list(texts)
|
||||
for img in images:
|
||||
jpeg_bytes = to_jpeg(img)
|
||||
to_encode = jpeg_bytes if jpeg_bytes is not None else img
|
||||
inputs.append({"image": base64.b64encode(to_encode).decode("utf-8")})
|
||||
|
||||
payload: dict[str, Any] = {"model": self.genai_config.model, "input": inputs}
|
||||
for key in ("options", "keep_alive"):
|
||||
if key in self.genai_config.provider_options:
|
||||
payload[key] = self.genai_config.provider_options[key]
|
||||
|
||||
try:
|
||||
# The ollama SDK's embed() validates input as strings only, so
|
||||
# image items have to bypass it and post the JSON directly.
|
||||
response = self.provider._request_raw("POST", "/api/embed", json=payload)
|
||||
body = response.json()
|
||||
except (
|
||||
TimeoutException,
|
||||
ResponseError,
|
||||
RemoteProtocolError,
|
||||
ConnectionError,
|
||||
ValueError,
|
||||
) as e:
|
||||
logger.warning("Ollama embeddings error: %s", str(e))
|
||||
return []
|
||||
|
||||
vectors = body.get("embeddings") if isinstance(body, dict) else None
|
||||
if not isinstance(vectors, list):
|
||||
logger.warning("Ollama embeddings returned unexpected format")
|
||||
return []
|
||||
|
||||
if len(vectors) != len(inputs):
|
||||
logger.warning(
|
||||
"Ollama returned %d embeddings for %d inputs",
|
||||
len(vectors),
|
||||
len(inputs),
|
||||
)
|
||||
|
||||
return [np.asarray(v, dtype=np.float32).flatten() for v in vectors]
|
||||
|
||||
def _build_request_params(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
"""Shared helpers for GenAI providers and chat (OpenAI-style messages, tool call parsing)."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def to_jpeg(img_bytes: bytes) -> bytes | None:
|
||||
"""Convert image bytes to JPEG.
|
||||
|
||||
Some provider image decoders (e.g. llama.cpp's STB) do not support WebP,
|
||||
which is the format Frigate stores thumbnails in.
|
||||
"""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(img_bytes))
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB") # type: ignore[assignment]
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
return buf.getvalue()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to convert image to JPEG: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def synthetic_jpeg(width: int, height: int) -> bytes:
|
||||
"""A flat gray JPEG of the given dimensions, for measuring image token cost."""
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (width, height), (128, 128, 128)).save(
|
||||
buf, format="JPEG", quality=60
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def interleave_images(
|
||||
prompt: str, images: list[bytes], captions: list[str] | None = None
|
||||
) -> list[str | bytes]:
|
||||
|
||||
@@ -15,7 +15,7 @@ from peewee import DoesNotExist
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config.camera.record import ChaptersEnum
|
||||
from frigate.const import EXPORT_DIR, UPDATE_JOB_STATE
|
||||
from frigate.const import UPDATE_JOB_STATE
|
||||
from frigate.jobs.job import Job
|
||||
from frigate.models import Export
|
||||
from frigate.record.export import (
|
||||
@@ -415,10 +415,6 @@ def reap_stale_exports() -> None:
|
||||
this in a try/except. A failure on a single row will not stop the rest
|
||||
of the sweep, and a failure in the top-level query will log and return.
|
||||
"""
|
||||
# staged stream runs live on disk, so a killed export leaves them behind
|
||||
for staged in Path(EXPORT_DIR).glob("export_stage_*"):
|
||||
staged.unlink(missing_ok=True)
|
||||
|
||||
try:
|
||||
stale_exports = list(Export.select().where(Export.in_progress == True)) # noqa: E712
|
||||
except Exception:
|
||||
|
||||
@@ -85,6 +85,7 @@ def apply_log_levels(default: str, log_levels: dict[str, LogLevel]) -> None:
|
||||
log_levels = {
|
||||
"absl": LogLevel.error,
|
||||
"httpx": LogLevel.error,
|
||||
"httpx2": LogLevel.error,
|
||||
"h5py": LogLevel.error,
|
||||
"keras": LogLevel.error,
|
||||
"matplotlib": LogLevel.error,
|
||||
|
||||
@@ -73,6 +73,15 @@ _KINDS = (
|
||||
"detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
|
||||
),
|
||||
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
|
||||
# every camera process raises it when the shared queue backs up, so repeats
|
||||
# wait for the next flush instead of writing once per camera
|
||||
NoticeKind(
|
||||
"object_processing_behind",
|
||||
NoticeSeverity.warning,
|
||||
"system",
|
||||
link="/system#cameras",
|
||||
batch_repeats=True,
|
||||
),
|
||||
# one row per user per burst; the login log lines carry the address
|
||||
NoticeKind(
|
||||
"failed_login",
|
||||
|
||||
@@ -360,7 +360,7 @@ class ObjectDetectProcess:
|
||||
# detection_start is set only after detection_queue.get()
|
||||
# returns. If it was reset during the grace period, the process
|
||||
# recovered and may be waiting on the shared queue again.
|
||||
if self.detection_start.value == 0.0: # type: ignore[attr-defined]
|
||||
if self.detection_start.value == 0.0:
|
||||
logging.info("Detection process recovered before restart")
|
||||
return
|
||||
|
||||
@@ -369,7 +369,7 @@ class ObjectDetectProcess:
|
||||
self.detect_process.join()
|
||||
logging.info("Detection process has exited...")
|
||||
|
||||
self.detection_start.value = 0.0 # type: ignore[attr-defined]
|
||||
self.detection_start.value = 0.0
|
||||
|
||||
# Async path for MemryX
|
||||
if self.detector_config.type == "memryx":
|
||||
|
||||
@@ -30,6 +30,9 @@ from frigate.util.image import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Minimum seconds to hold the current camera before switching to a different one
|
||||
CAMERA_HOLD_SECONDS = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BirdseyeActivity:
|
||||
@@ -496,8 +499,11 @@ class BirdsEyeFrameManager:
|
||||
if max_cameras:
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
if len(active_cameras) == max_cameras and now - self.last_refresh_time < 10:
|
||||
# don't refresh cameras too often
|
||||
if (
|
||||
len(self.active_cameras) == max_cameras
|
||||
and len(active_cameras) >= max_cameras
|
||||
and now - self.last_refresh_time < CAMERA_HOLD_SECONDS
|
||||
):
|
||||
active_cameras = self.active_cameras
|
||||
else:
|
||||
limited_active_cameras = sorted(
|
||||
|
||||
@@ -10,7 +10,7 @@ from multiprocessing.synchronize import Event as MpEvent
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
|
||||
from frigate.const import (
|
||||
@@ -88,7 +88,7 @@ class RecordingCleanup(threading.Thread):
|
||||
if (
|
||||
os.stat(f"{self.config.database.path}-wal").st_size / (1024 * 1024)
|
||||
) > MAX_WAL_SIZE:
|
||||
db = SqliteExtDatabase(self.config.database.path)
|
||||
db = SqliteDatabase(self.config.database.path)
|
||||
db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
import pytz
|
||||
from pathvalidate import sanitize_filename
|
||||
from peewee import DoesNotExist
|
||||
|
||||
@@ -559,7 +559,7 @@ class RecordingExporter(threading.Thread):
|
||||
)
|
||||
|
||||
def _staged_run_path(self, index: int) -> str:
|
||||
return os.path.join(EXPORT_DIR, f"export_stage_{self.export_id}_{index}.mp4")
|
||||
return os.path.join(CACHE_DIR, f"export_stage_{self.export_id}_{index}.mp4")
|
||||
|
||||
def _probe_stream_resolution(self, run: StreamRun) -> tuple[int, int] | None:
|
||||
"""Probe one recording from a run for its resolution.
|
||||
|
||||
@@ -859,7 +859,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
|
||||
for box in motion_boxes:
|
||||
if len(box) < 4:
|
||||
continue
|
||||
continue # type: ignore[unreachable]
|
||||
x1, y1, x2, y2 = box
|
||||
|
||||
# Convert pixel coordinates to grid cells
|
||||
|
||||
@@ -6,8 +6,8 @@ from unittest.mock import patch
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.testclient import TestClient
|
||||
from peewee import SqliteDatabase
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
from pydantic import Json
|
||||
|
||||
@@ -38,7 +38,7 @@ class AuthTestClient(TestClient):
|
||||
class BaseTestHttp(unittest.TestCase):
|
||||
def setUp(self, models):
|
||||
# setup clean database for each test run
|
||||
migrate_db = SqliteExtDatabase("test.db")
|
||||
migrate_db = SqliteDatabase("test.db")
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
router = Router(migrate_db)
|
||||
router.run()
|
||||
|
||||
@@ -453,21 +453,6 @@ class TestHttpExport(BaseTestHttp):
|
||||
assert unchanged.name == "front door export"
|
||||
assert unchanged.video_path == video
|
||||
|
||||
def test_reap_stale_exports_removes_staged_runs(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
staged = os.path.join(tmpdir, "export_stage_front_door_abc_0.mp4")
|
||||
finished = os.path.join(tmpdir, "front_door_export.mp4")
|
||||
|
||||
for path in (staged, finished):
|
||||
with open(path, "w") as handle:
|
||||
handle.write("video")
|
||||
|
||||
with patch("frigate.jobs.export.EXPORT_DIR", tmpdir):
|
||||
reap_stale_exports()
|
||||
|
||||
assert not os.path.exists(staged)
|
||||
assert os.path.exists(finished)
|
||||
|
||||
def test_reap_stale_exports_deletes_rows_with_no_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
stale_video = os.path.join(tmpdir, "stale.mp4")
|
||||
|
||||
@@ -500,6 +500,80 @@ class TestBirdseyeLiveActivity(unittest.TestCase):
|
||||
assert self.manager.active_cameras == {"front"}
|
||||
|
||||
|
||||
class TestBirdseyeCameraHold(unittest.TestCase):
|
||||
"""Test that CAMERA_HOLD_SECONDS prevents rapid camera switching."""
|
||||
|
||||
def setUp(self):
|
||||
config = {
|
||||
"mqtt": {"enabled": False},
|
||||
"birdseye": {
|
||||
"enabled": True,
|
||||
"modes": ["motion"],
|
||||
"inactivity_threshold": 30,
|
||||
},
|
||||
"cameras": {
|
||||
camera: {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
}
|
||||
for camera in ("back", "front")
|
||||
},
|
||||
}
|
||||
self.config = FrigateConfig(**config)
|
||||
self.manager = BirdsEyeFrameManager(self.config, mp.Event())
|
||||
|
||||
for camera_data in self.manager.cameras.values():
|
||||
camera_data["current_frame"] = None
|
||||
camera_data["current_frame_time"] = 100.0
|
||||
camera_data["last_active_frame"] = 0.0
|
||||
camera_data["live_active"] = False
|
||||
|
||||
def test_max_cameras_cooldown_applies_when_more_active_than_max(self):
|
||||
"""The max_cameras cooldown should apply even when more cameras are active than max."""
|
||||
self.config.birdseye.layout.max_cameras = 1
|
||||
self.manager.cameras["front"]["last_active_frame"] = 95.0
|
||||
self.manager.cameras["front"]["current_frame_time"] = 100.0
|
||||
self.manager.update_frame()
|
||||
assert "front" in self.manager.active_cameras
|
||||
|
||||
self.manager.cameras["back"]["last_active_frame"] = 99.0
|
||||
self.manager.cameras["back"]["current_frame_time"] = 100.0
|
||||
self.manager.update_frame()
|
||||
|
||||
assert "front" in self.manager.active_cameras
|
||||
|
||||
def test_camera_count_change_ignores_hold(self):
|
||||
"""Adding a camera (count change) should not be blocked by the hold period."""
|
||||
self.manager.cameras["front"]["last_active_frame"] = 95.0
|
||||
self.manager.update_frame()
|
||||
assert self.manager.active_cameras == {"front"}
|
||||
|
||||
self.manager.cameras["back"]["last_active_frame"] = 99.0
|
||||
self.manager.cameras["back"]["current_frame_time"] = 100.0
|
||||
self.manager.cameras["front"]["last_active_frame"] = 95.0
|
||||
self.manager.update_frame()
|
||||
|
||||
assert self.manager.active_cameras == {"front", "back"}
|
||||
|
||||
def test_max_cameras_count_increase_not_blocked_by_hold(self):
|
||||
"""With max_cameras=2 showing 1, a second active camera must appear immediately."""
|
||||
self.config.birdseye.layout.max_cameras = 2
|
||||
self.manager.cameras["front"]["last_active_frame"] = 95.0
|
||||
self.manager.cameras["front"]["current_frame_time"] = 100.0
|
||||
self.manager.update_frame()
|
||||
assert self.manager.active_cameras == {"front"}
|
||||
|
||||
self.manager.cameras["back"]["last_active_frame"] = 99.0
|
||||
self.manager.cameras["back"]["current_frame_time"] = 100.0
|
||||
self.manager.update_frame()
|
||||
|
||||
assert self.manager.active_cameras == {"front", "back"}
|
||||
|
||||
|
||||
class TestBirdseyeModePayload(unittest.TestCase):
|
||||
"""Test the MQTT payload contract for Birdseye activity modes."""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from frigate.api.chat import (
|
||||
_execute_find_similar_objects,
|
||||
@@ -98,7 +98,7 @@ class TestExecuteFindSimilarObjects(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
self.tmp.close()
|
||||
self.db = SqliteExtDatabase(self.tmp.name)
|
||||
self.db = SqliteDatabase(self.tmp.name)
|
||||
Event.bind(self.db, bind_refs=False, bind_backrefs=False)
|
||||
self.db.connect()
|
||||
self.db.create_tables([Event])
|
||||
|
||||
@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from frigate.api.chat import (
|
||||
TOOL_REJECTED_RESULT,
|
||||
@@ -214,7 +214,7 @@ class DatabaseTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
self.tmp.close()
|
||||
self.db = SqliteExtDatabase(self.tmp.name)
|
||||
self.db = SqliteDatabase(self.tmp.name)
|
||||
for model in self.models:
|
||||
model.bind(self.db, bind_refs=False, bind_backrefs=False)
|
||||
self.db.connect()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.const import INSERT_MANY_RECORDINGS
|
||||
@@ -30,7 +30,7 @@ class TestInsertManyRecordings(unittest.TestCase):
|
||||
"""A duplicate path must not cost the rest of the batch."""
|
||||
|
||||
def setUp(self):
|
||||
self.db = SqliteExtDatabase(":memory:")
|
||||
self.db = SqliteDatabase(":memory:")
|
||||
self.db.bind([Recordings])
|
||||
self.db.create_tables([Recordings])
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for the notice raised when object processing drops camera frames."""
|
||||
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.notices.types import NOTICE_KINDS
|
||||
from frigate.video.detect import (
|
||||
DROPPED_FRAMES_NOTICE_COUNT,
|
||||
DROPPED_FRAMES_NOTICE_INTERVAL_S,
|
||||
DROPPED_FRAMES_WINDOW_S,
|
||||
DroppedFrameTracker,
|
||||
)
|
||||
|
||||
|
||||
class TestDroppedFrameTracker(unittest.TestCase):
|
||||
def setUp(self):
|
||||
raise_patch = patch("frigate.video.detect.raise_notice")
|
||||
self.raise_notice = raise_patch.start()
|
||||
self.addCleanup(raise_patch.stop)
|
||||
|
||||
def _drop(self, tracker: DroppedFrameTracker, count: int, start: float) -> None:
|
||||
for i in range(count):
|
||||
tracker.dropped(start + i * 0.2)
|
||||
|
||||
# the notice is sent on a background thread
|
||||
if tracker._sender is not None:
|
||||
tracker._sender.join(timeout=5)
|
||||
|
||||
def test_kind_is_registered(self):
|
||||
self.assertIn("object_processing_behind", NOTICE_KINDS)
|
||||
|
||||
def test_a_few_drops_raise_nothing(self):
|
||||
tracker = DroppedFrameTracker("front_door")
|
||||
|
||||
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT - 1, 0.0)
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
|
||||
def test_enough_drops_raise_the_notice(self):
|
||||
tracker = DroppedFrameTracker("front_door")
|
||||
|
||||
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT, 0.0)
|
||||
|
||||
self.raise_notice.assert_called_once_with("object_processing_behind")
|
||||
|
||||
def test_drops_outside_the_window_do_not_add_up(self):
|
||||
tracker = DroppedFrameTracker("front_door")
|
||||
|
||||
for i in range(DROPPED_FRAMES_NOTICE_COUNT):
|
||||
tracker.dropped(i * (DROPPED_FRAMES_WINDOW_S + 1.0))
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
|
||||
def test_repeats_wait_for_the_interval(self):
|
||||
tracker = DroppedFrameTracker("front_door")
|
||||
|
||||
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT, 0.0)
|
||||
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT * 2, 5.0)
|
||||
self.assertEqual(self.raise_notice.call_count, 1)
|
||||
|
||||
self._drop(
|
||||
tracker, DROPPED_FRAMES_NOTICE_COUNT, DROPPED_FRAMES_NOTICE_INTERVAL_S
|
||||
)
|
||||
self.assertEqual(self.raise_notice.call_count, 2)
|
||||
|
||||
def test_a_pending_send_does_not_block_or_start_another(self):
|
||||
sending = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def wait_for_reply(*_) -> None:
|
||||
sending.set()
|
||||
release.wait(5)
|
||||
|
||||
self.raise_notice.side_effect = wait_for_reply
|
||||
tracker = DroppedFrameTracker("front_door")
|
||||
|
||||
# the first notice gets no reply while the second burst arrives
|
||||
for i in range(DROPPED_FRAMES_NOTICE_COUNT):
|
||||
tracker.dropped(i * 0.2)
|
||||
|
||||
self.assertTrue(sending.wait(5))
|
||||
|
||||
for i in range(DROPPED_FRAMES_NOTICE_COUNT):
|
||||
tracker.dropped(DROPPED_FRAMES_NOTICE_INTERVAL_S + i * 0.2)
|
||||
|
||||
self.assertEqual(self.raise_notice.call_count, 1)
|
||||
|
||||
release.set()
|
||||
assert tracker._sender is not None
|
||||
tracker._sender.join(timeout=5)
|
||||
|
||||
def test_replay_camera_never_raises(self):
|
||||
tracker = DroppedFrameTracker(f"{REPLAY_CAMERA_PREFIX}front_door")
|
||||
|
||||
self._drop(tracker, DROPPED_FRAMES_NOTICE_COUNT * 2, 0.0)
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,7 +4,7 @@ import datetime
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.events.cleanup import EventCleanup
|
||||
@@ -15,7 +15,7 @@ class TestEventCleanupSubRetention(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# in-memory database keeps these tests isolated from the shared
|
||||
# on-disk test.db used by the http api tests
|
||||
self.db = SqliteExtDatabase(":memory:")
|
||||
self.db = SqliteDatabase(":memory:")
|
||||
models = [Event, Timeline]
|
||||
self.db.bind(models)
|
||||
self.db.create_tables(models)
|
||||
|
||||
@@ -427,6 +427,148 @@ class TestOllamaProvider(unittest.TestCase):
|
||||
self.assertEqual(message["content"], "prompt\n[img]")
|
||||
self.assertEqual(message["images"], [b"a"])
|
||||
|
||||
def test_capabilities_drive_embeddings_and_thinking(self):
|
||||
client = self._client()
|
||||
client.provider = MagicMock()
|
||||
client.provider.show.return_value = {"capabilities": ["embedding", "vision"]}
|
||||
|
||||
self.assertTrue(client.supports_embeddings)
|
||||
self.assertFalse(client.supports_toggleable_thinking)
|
||||
client.provider.show.assert_called_once()
|
||||
|
||||
def test_capability_lookup_failure_is_not_cached(self):
|
||||
from ollama import ResponseError
|
||||
|
||||
client = self._client()
|
||||
client.provider = MagicMock()
|
||||
client.provider.show.side_effect = [
|
||||
ResponseError("unavailable", 503),
|
||||
{"capabilities": ["embedding"]},
|
||||
]
|
||||
|
||||
self.assertFalse(client.supports_embeddings)
|
||||
self.assertTrue(client.supports_embeddings)
|
||||
|
||||
def test_thinking_rechecked_after_provider_recovers(self):
|
||||
client = self._client()
|
||||
client.provider = None
|
||||
|
||||
self.assertFalse(client.supports_toggleable_thinking)
|
||||
|
||||
client.provider = MagicMock()
|
||||
client.provider.show.return_value = {"capabilities": ["thinking"]}
|
||||
|
||||
self.assertTrue(client.supports_toggleable_thinking)
|
||||
params = client._build_request_params(
|
||||
[{"role": "user", "content": "hi"}], None, None, enable_thinking=True
|
||||
)
|
||||
self.assertTrue(params["think"])
|
||||
|
||||
@staticmethod
|
||||
def _webp_bytes():
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (200, 10, 10)).save(buf, format="WEBP")
|
||||
return buf.getvalue()
|
||||
|
||||
def test_embed_posts_text_and_image_items(self):
|
||||
client = self._client()
|
||||
client.provider = MagicMock()
|
||||
client.provider._request_raw.return_value.json.return_value = {
|
||||
"embeddings": [[0.1] * 768, [0.2] * 768]
|
||||
}
|
||||
|
||||
result = client.embed(texts=["a person"], images=[self._webp_bytes()])
|
||||
|
||||
args = client.provider._request_raw.call_args
|
||||
self.assertEqual(args.args, ("POST", "/api/embed"))
|
||||
payload = args.kwargs["json"]
|
||||
self.assertEqual(payload["model"], "llama3")
|
||||
self.assertEqual(payload["input"][0], "a person")
|
||||
self.assertEqual(list(payload["input"][1]), ["image"])
|
||||
# WebP thumbnails are converted to JPEG before being sent
|
||||
image = base64.b64decode(payload["input"][1]["image"])
|
||||
self.assertEqual(image[:2], b"\xff\xd8")
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertAlmostEqual(float(result[1][0]), 0.2, places=5)
|
||||
|
||||
def test_embed_passes_configured_options(self):
|
||||
client = _make_client(
|
||||
"ollama",
|
||||
model="embeddinggemma-2",
|
||||
base_url="http://localhost:9999",
|
||||
provider_options={"options": {"num_ctx": 2048}, "keep_alive": "10m"},
|
||||
)
|
||||
client.provider = MagicMock()
|
||||
client.provider._request_raw.return_value.json.return_value = {
|
||||
"embeddings": [[0.1] * 768]
|
||||
}
|
||||
|
||||
client.embed(texts=["a"])
|
||||
|
||||
payload = client.provider._request_raw.call_args.kwargs["json"]
|
||||
self.assertEqual(payload["options"], {"num_ctx": 2048})
|
||||
self.assertEqual(payload["keep_alive"], "10m")
|
||||
|
||||
@staticmethod
|
||||
def _chat_counting_prompt_tokens(**params):
|
||||
"""Fake chat that reports 10 prompt tokens plus 250 per image."""
|
||||
images = params["messages"][0].get("images") or []
|
||||
return {
|
||||
"message": {"content": "."},
|
||||
"prompt_eval_count": 10 + 250 * len(images),
|
||||
}
|
||||
|
||||
def test_image_tokens_probed_with_one_token_requests(self):
|
||||
client = _make_client(
|
||||
"ollama",
|
||||
model="qwen3-vl",
|
||||
base_url="http://localhost:9999",
|
||||
provider_options={"options": {"num_ctx": 16384}},
|
||||
)
|
||||
client.provider = MagicMock()
|
||||
client.provider.chat.side_effect = self._chat_counting_prompt_tokens
|
||||
client._supports_thinking_cache = False
|
||||
|
||||
self.assertEqual(client.estimate_image_tokens(320, 180), 250)
|
||||
|
||||
calls = client.provider.chat.call_args_list
|
||||
self.assertEqual(len(calls), 2)
|
||||
for call in calls:
|
||||
self.assertEqual(
|
||||
call.kwargs["options"], {"num_ctx": 16384, "num_predict": 1}
|
||||
)
|
||||
image = calls[1].kwargs["messages"][0]["images"][0]
|
||||
self.assertEqual(image[:2], b"\xff\xd8")
|
||||
|
||||
def test_image_token_probe_error_uses_heuristic(self):
|
||||
from ollama import ResponseError
|
||||
|
||||
client = self._client()
|
||||
client.provider = MagicMock()
|
||||
client.provider.chat.side_effect = [
|
||||
{"message": {"content": "."}, "prompt_eval_count": 10},
|
||||
ResponseError("model does not support images", 400),
|
||||
]
|
||||
client._supports_thinking_cache = False
|
||||
|
||||
self.assertEqual(client.estimate_image_tokens(250, 100), 20)
|
||||
self.assertEqual(client._image_token_cache, {})
|
||||
|
||||
def test_embed_server_error_returns_empty(self):
|
||||
from ollama import ResponseError
|
||||
|
||||
client = self._client()
|
||||
client.provider = MagicMock()
|
||||
client.provider._request_raw.side_effect = ResponseError(
|
||||
"model does not support media embeddings", 400
|
||||
)
|
||||
|
||||
self.assertEqual(client.embed(images=[self._webp_bytes()]), [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# llama.cpp
|
||||
@@ -638,6 +780,46 @@ class TestLlamaCppProvider(unittest.TestCase):
|
||||
self.assertEqual([r.shape for r in result], [(768,), (768,)])
|
||||
self.assertEqual(float(result[1][-1]), 0.0)
|
||||
|
||||
@staticmethod
|
||||
def _post_counting_prompt_tokens(url, json=None, timeout=None):
|
||||
"""Fake chat completion: 12 prompt tokens plus 300 per image part."""
|
||||
content = json["messages"][0]["content"]
|
||||
images = [p for p in content if p["type"] == "image_url"]
|
||||
response = MagicMock()
|
||||
response.json.return_value = {
|
||||
"usage": {"prompt_tokens": 12 + 300 * len(images)}
|
||||
}
|
||||
return response
|
||||
|
||||
def test_image_tokens_probed_once_per_dimension(self):
|
||||
client = self._client()
|
||||
|
||||
with patch.object(
|
||||
client, "_post", side_effect=self._post_counting_prompt_tokens
|
||||
) as post:
|
||||
self.assertEqual(client.estimate_image_tokens(320, 180), 300)
|
||||
self.assertEqual(client.estimate_image_tokens(320, 180), 300)
|
||||
self.assertEqual(client.estimate_image_tokens(640, 360), 300)
|
||||
|
||||
# one shared text baseline, then one image request per new dimension
|
||||
self.assertEqual(post.call_count, 3)
|
||||
payload = post.call_args_list[0].kwargs["json"]
|
||||
self.assertEqual(payload["max_tokens"], 1)
|
||||
self.assertEqual(
|
||||
post.call_args_list[0].args[0], "http://localhost:9999/v1/chat/completions"
|
||||
)
|
||||
|
||||
def test_image_token_probe_failure_is_not_cached(self):
|
||||
client = self._client()
|
||||
|
||||
with patch.object(
|
||||
client, "_post", side_effect=requests.exceptions.ConnectionError("down")
|
||||
):
|
||||
self.assertEqual(client.estimate_image_tokens(250, 100), 20)
|
||||
|
||||
self.assertEqual(client._image_token_cache, {})
|
||||
self.assertIsNone(client._text_baseline_tokens)
|
||||
|
||||
def test_embed_request_error_returns_empty(self):
|
||||
client = self._client()
|
||||
response = MagicMock()
|
||||
@@ -903,6 +1085,14 @@ class TestLlamaCppTranscribe(unittest.TestCase):
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
class TestImageTokenEstimate(unittest.TestCase):
|
||||
def test_provider_without_token_counts_uses_heuristic(self):
|
||||
client = _make_client("gemini", model="m", api_key="k")
|
||||
|
||||
self.assertEqual(client.estimate_image_tokens(250, 100), 20)
|
||||
self.assertEqual(client._image_token_cache, {})
|
||||
|
||||
|
||||
class TestBaseClientTranscribe(unittest.TestCase):
|
||||
"""Providers that don't implement the role must be inert, not broken."""
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import logging
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from peewee import SqliteDatabase
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
from frigate.api.media_auth import (
|
||||
@@ -208,7 +208,7 @@ class TestExportResolution(unittest.TestCase):
|
||||
"""Export resolution requires a DB lookup."""
|
||||
|
||||
def setUp(self):
|
||||
migrate_db = SqliteExtDatabase("test.db")
|
||||
migrate_db = SqliteDatabase("test.db")
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
Router(migrate_db).run()
|
||||
migrate_db.close()
|
||||
|
||||
@@ -126,12 +126,18 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
os.makedirs(MODEL_CACHE_DIR)
|
||||
|
||||
self.config = build_config()
|
||||
self.client = MqttClient(self.config)
|
||||
self.client = self._build_client()
|
||||
self.receiver = RuntimeSnapshotReceiver()
|
||||
self.client.attach_dispatcher(build_dispatcher(self.config, []))
|
||||
|
||||
def test_subscribe_stores_receiver_without_starting_worker(self) -> None:
|
||||
def _build_client(self) -> MqttClient:
|
||||
client = MqttClient(self.config)
|
||||
self.addCleanup(client._wake_recv.close)
|
||||
self.addCleanup(client._wake_send.close)
|
||||
return client
|
||||
|
||||
def test_subscribe_stores_receiver_without_starting_worker(self) -> None:
|
||||
client = self._build_client()
|
||||
|
||||
with patch.object(client, "_start_worker") as mock_start_worker:
|
||||
client.subscribe(self.receiver._receive)
|
||||
@@ -142,7 +148,7 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
mock_start_worker.assert_not_called()
|
||||
|
||||
def test_attach_dispatcher_supplies_command_surface(self) -> None:
|
||||
client = MqttClient(self.config)
|
||||
client = self._build_client()
|
||||
|
||||
self.assertFalse(client._is_supported_command_topic("front/detect/set"))
|
||||
|
||||
@@ -295,6 +301,13 @@ class TestMqttClientLifecycle(unittest.TestCase):
|
||||
self.assertEqual(self.client._subscription_mid, 42)
|
||||
self.client.client.subscribe.assert_called_once_with("frigate/#", qos=0)
|
||||
|
||||
def test_publish_wakes_worker(self) -> None:
|
||||
self.client.connected = True
|
||||
|
||||
self.client.publish("events", "payload")
|
||||
|
||||
self.assertEqual(self.client._wake_recv.recv(16), b"\0")
|
||||
|
||||
def test_handle_connect_event_reconnects_on_recoverable_subscribe_error(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
@@ -2,6 +2,7 @@ import fcntl
|
||||
import resource
|
||||
import selectors
|
||||
import socket
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -23,6 +24,11 @@ class TestMqttNetworkLoop(unittest.TestCase):
|
||||
self.sock, self.peer = socket.socketpair()
|
||||
self.addCleanup(self.sock.close)
|
||||
self.addCleanup(self.peer.close)
|
||||
self.transport._wake_recv, self.transport._wake_send = socket.socketpair()
|
||||
self.transport._wake_recv.setblocking(False)
|
||||
self.transport._wake_send.setblocking(False)
|
||||
self.addCleanup(self.transport._wake_recv.close)
|
||||
self.addCleanup(self.transport._wake_send.close)
|
||||
self.client.socket.return_value = self.sock
|
||||
|
||||
def test_high_fd_handles_connack_suback_publish_and_puback(self) -> None:
|
||||
@@ -123,8 +129,18 @@ class TestMqttNetworkLoop(unittest.TestCase):
|
||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_CONN_LOST)
|
||||
self.client.loop_misc.assert_not_called()
|
||||
|
||||
def test_missing_client_or_socket_reports_no_connection(self) -> None:
|
||||
def test_missing_socket_reports_no_connection(self) -> None:
|
||||
self.client.socket.return_value = None
|
||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
|
||||
self.transport.client = None
|
||||
self.assertEqual(self.transport._loop_client(0), mqtt.MQTT_ERR_NO_CONN)
|
||||
|
||||
def test_wake_interrupts_wait_and_is_consumed(self) -> None:
|
||||
self.transport._wake_worker()
|
||||
|
||||
start = time.monotonic()
|
||||
self.assertEqual(self.transport._loop_client(5), mqtt.MQTT_ERR_SUCCESS)
|
||||
self.assertLess(time.monotonic() - start, 1)
|
||||
self.client.loop_read.assert_not_called()
|
||||
|
||||
start = time.monotonic()
|
||||
self.transport._loop_client(0.2)
|
||||
self.assertGreater(time.monotonic() - start, 0.15)
|
||||
|
||||
@@ -5,8 +5,8 @@ import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from peewee import SqliteDatabase
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
from frigate.models import Notice, NoticeStats
|
||||
@@ -49,7 +49,7 @@ class TestNoticeKinds(unittest.TestCase):
|
||||
|
||||
class RegistryTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
migrate_db = SqliteExtDatabase("test.db")
|
||||
migrate_db = SqliteDatabase("test.db")
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
router = Router(migrate_db)
|
||||
router.run()
|
||||
|
||||
@@ -4,7 +4,7 @@ import datetime
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
|
||||
@@ -15,7 +15,7 @@ class TestRecordingCleanupSubRetention(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# in-memory database keeps these tests isolated from the shared
|
||||
# on-disk test.db used by the http api tests
|
||||
self.db = SqliteExtDatabase(":memory:")
|
||||
self.db = SqliteDatabase(":memory:")
|
||||
models = [Previews, Recordings, ReviewSegment, UserReviewStatus]
|
||||
self.db.bind(models)
|
||||
self.db.create_tables(models)
|
||||
|
||||
@@ -8,7 +8,7 @@ import unittest
|
||||
from collections import defaultdict
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.models import Recordings
|
||||
@@ -719,7 +719,7 @@ class TestSegmentChainSeeding(unittest.IsolatedAsyncioTestCase):
|
||||
T0 = datetime.datetime(2026, 6, 10, 14, 30, 22, tzinfo=datetime.UTC).timestamp()
|
||||
|
||||
def setUp(self):
|
||||
self.db = SqliteExtDatabase(":memory:")
|
||||
self.db = SqliteDatabase(":memory:")
|
||||
self.db.bind([Recordings])
|
||||
self.db.create_tables([Recordings])
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
from frigate.api.media import _vod_response
|
||||
from frigate.const import MAX_SEGMENT_DURATION
|
||||
@@ -13,7 +13,6 @@ from frigate.models import Recordings
|
||||
from frigate.util.recording_coverage import (
|
||||
_rows_query,
|
||||
coverage_spans,
|
||||
null_audio_glitches,
|
||||
plan_clip,
|
||||
realized_timeline,
|
||||
resolve_coverage,
|
||||
@@ -25,7 +24,7 @@ class CoverageDbTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# in-memory database keeps these tests isolated from the shared
|
||||
# on-disk test.db used by the http api tests
|
||||
self.db = SqliteExtDatabase(":memory:")
|
||||
self.db = SqliteDatabase(":memory:")
|
||||
models = [Recordings]
|
||||
self.db.bind(models)
|
||||
self.db.create_tables(models)
|
||||
@@ -167,22 +166,6 @@ class TestRecordingCoverage(CoverageDbTestCase):
|
||||
expected = int(5 * 1024 * 1024 * 8 / 10)
|
||||
assert summary["main"]["bitrate"] == expected
|
||||
|
||||
def test_unknown_audio_row_keeps_video_only_stream(self):
|
||||
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=False)
|
||||
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=None)
|
||||
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=False)
|
||||
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
|
||||
self.assertEqual(
|
||||
[i.sub.path for i in kept], [f"/tmp/s{n}.mp4" for n in (1, 2, 3)]
|
||||
)
|
||||
|
||||
def test_video_only_glitch_dropped_on_audio_stream(self):
|
||||
self._insert("s1", 1000.0, 1010.0, "sub", has_audio=True)
|
||||
self._insert("s2", 1010.0, 1020.0, "sub", has_audio=False)
|
||||
self._insert("s3", 1020.0, 1030.0, "sub", has_audio=None)
|
||||
kept = null_audio_glitches(resolve_coverage("front_door", 1000.0, 1030.0))
|
||||
self.assertEqual([i.sub.path for i in kept], ["/tmp/s1.mp4", "/tmp/s3.mp4"])
|
||||
|
||||
def test_other_camera_rows_excluded(self):
|
||||
self._insert("m1", 1000.0, 1010.0, "main")
|
||||
self._insert("o1", 1000.0, 1010.0, "main", camera="back_yard")
|
||||
|
||||
@@ -5,9 +5,8 @@ import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from peewee import DoesNotExist
|
||||
from peewee import DoesNotExist, SqliteDatabase
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
@@ -20,7 +19,7 @@ from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
class TestHttp(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# setup clean database for each test run
|
||||
migrate_db = SqliteExtDatabase("test.db")
|
||||
migrate_db = SqliteDatabase("test.db")
|
||||
del logging.getLogger("peewee_migrate").handlers[:]
|
||||
router = Router(migrate_db)
|
||||
router.run()
|
||||
|
||||
@@ -747,14 +747,8 @@ def collect_object_classification_examples(
|
||||
selected_events = _select_balanced_events(events, target_count=100)
|
||||
logger.debug(f"Selected {len(selected_events)} events")
|
||||
|
||||
# Step 3: Extract thumbnails from events, falling back to the remaining
|
||||
# events when the selected ones have no image on disk
|
||||
selected_ids = {e.id for e in selected_events}
|
||||
remaining_events = [e for e in events if e.id not in selected_ids]
|
||||
random.shuffle(remaining_events)
|
||||
thumbnails = _extract_event_thumbnails(
|
||||
selected_events + remaining_events, temp_dir, target_count=100
|
||||
)
|
||||
# Step 3: Extract thumbnails from events
|
||||
thumbnails = _extract_event_thumbnails(selected_events, temp_dir)
|
||||
logger.debug(f"Successfully extracted {len(thumbnails)} thumbnails")
|
||||
|
||||
# Step 4: Select 24 most visually distinct thumbnails
|
||||
@@ -839,16 +833,10 @@ def _select_balanced_events(
|
||||
else:
|
||||
selected.extend(remaining)
|
||||
|
||||
# groups are ordered oldest first, so truncating unshuffled keeps only the
|
||||
# oldest events, which are the least likely to still have images on disk
|
||||
random.shuffle(selected)
|
||||
|
||||
return selected[:target_count]
|
||||
|
||||
|
||||
def _extract_event_thumbnails(
|
||||
events: list[Event], output_dir: str, target_count: int = 100
|
||||
) -> list[str]:
|
||||
def _extract_event_thumbnails(events: list[Event], output_dir: str) -> list[str]:
|
||||
"""
|
||||
Extract a training image for each event.
|
||||
|
||||
@@ -862,9 +850,8 @@ def _extract_event_thumbnails(
|
||||
using a step ladder sized from the box/region area ratio.
|
||||
|
||||
Args:
|
||||
events: List of Event objects, in order of preference
|
||||
events: List of Event objects
|
||||
output_dir: Directory to save crops
|
||||
target_count: Number of images to extract before stopping
|
||||
|
||||
Returns:
|
||||
List of paths to successfully extracted images
|
||||
@@ -872,9 +859,6 @@ def _extract_event_thumbnails(
|
||||
image_paths = []
|
||||
|
||||
for idx, event in enumerate(events):
|
||||
if len(image_paths) >= target_count:
|
||||
break
|
||||
|
||||
try:
|
||||
img = _load_event_classification_crop(event)
|
||||
if img is None:
|
||||
|
||||
@@ -204,12 +204,11 @@ def coverage_spans(intervals: list[CoverageInterval]) -> list[dict[str, Any]]:
|
||||
def stream_has_audio(intervals: list[CoverageInterval], main: bool) -> bool:
|
||||
"""Whether a stream is audio-bearing over a coverage window.
|
||||
|
||||
A stream counts as audio-bearing only when one of its rows is known
|
||||
to carry audio. A NULL row (legacy, or a segment ffprobe could not
|
||||
read) proves nothing either way.
|
||||
A stream counts as audio-bearing unless EVERY one of its rows reports
|
||||
has_audio False; NULL (legacy or undetermined) counts as audio.
|
||||
"""
|
||||
return any(
|
||||
row is not None and row.has_audio is True
|
||||
row is not None and row.has_audio is not False
|
||||
for row in ((interval.main if main else interval.sub) for interval in intervals)
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import UTC, datetime
|
||||
from multiprocessing import Queue
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
@@ -20,10 +22,12 @@ from frigate.config.camera.updater import (
|
||||
)
|
||||
from frigate.const import (
|
||||
PROCESS_PRIORITY_HIGH,
|
||||
REPLAY_CAMERA_PREFIX,
|
||||
REQUEST_REGION_GRID,
|
||||
)
|
||||
from frigate.motion import MotionDetector
|
||||
from frigate.motion.improved_motion import ImprovedMotionDetector
|
||||
from frigate.notices import raise_notice
|
||||
from frigate.object_detection.base import RemoteObjectDetector
|
||||
from frigate.ptz.autotrack import ptz_moving_at_frame_time
|
||||
from frigate.track import ObjectTracker
|
||||
@@ -52,6 +56,61 @@ from frigate.util.time import get_tomorrow_at_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# this many frames dropped within the window because the shared detected frames
|
||||
# queue was full means tracked object processing is falling behind
|
||||
DROPPED_FRAMES_NOTICE_COUNT = 5
|
||||
DROPPED_FRAMES_WINDOW_S = 30
|
||||
|
||||
DROPPED_FRAMES_NOTICE_INTERVAL_S = 60
|
||||
|
||||
|
||||
class DroppedFrameTracker:
|
||||
"""Raises a notice when a camera drops frames because object processing is behind."""
|
||||
|
||||
def __init__(self, camera: str) -> None:
|
||||
# a debug replay can feed frames faster than real time
|
||||
self._enabled = not camera.startswith(REPLAY_CAMERA_PREFIX)
|
||||
self._drops: deque[float] = deque()
|
||||
self._last_notice: float | None = None
|
||||
|
||||
# raising a notice waits on the main process, which answers every
|
||||
# process's requests one at a time, so it runs off the frame loop
|
||||
self._sender: threading.Thread | None = None
|
||||
|
||||
def dropped(self, now: float) -> None:
|
||||
"""Record a dropped frame and raise the notice if enough were dropped.
|
||||
|
||||
Args:
|
||||
now: Monotonic time of the drop in seconds
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
self._drops.append(now)
|
||||
|
||||
while now - self._drops[0] > DROPPED_FRAMES_WINDOW_S:
|
||||
self._drops.popleft()
|
||||
|
||||
if (
|
||||
len(self._drops) < DROPPED_FRAMES_NOTICE_COUNT
|
||||
or (
|
||||
self._last_notice is not None
|
||||
and now - self._last_notice < DROPPED_FRAMES_NOTICE_INTERVAL_S
|
||||
)
|
||||
or (self._sender is not None and self._sender.is_alive())
|
||||
):
|
||||
return
|
||||
|
||||
self._drops.clear()
|
||||
self._last_notice = now
|
||||
self._sender = threading.Thread(
|
||||
target=raise_notice,
|
||||
args=("object_processing_behind",),
|
||||
name="dropped_frames_notice",
|
||||
daemon=True,
|
||||
)
|
||||
self._sender.start()
|
||||
|
||||
|
||||
class CameraTracker(FrigateProcess):
|
||||
def __init__(
|
||||
@@ -207,6 +266,7 @@ def process_frames(
|
||||
|
||||
fps_tracker = EventsPerSecond()
|
||||
fps_tracker.start()
|
||||
dropped_frames = DroppedFrameTracker(camera_config.name)
|
||||
|
||||
startup_scan = True
|
||||
stationary_frame_counter = 0
|
||||
@@ -542,6 +602,7 @@ def process_frames(
|
||||
# add to the queue if not full
|
||||
if detected_objects_queue.full():
|
||||
frame_manager.close(frame_name)
|
||||
dropped_frames.dropped(time.monotonic())
|
||||
continue
|
||||
else:
|
||||
fps_tracker.update()
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class FrigateWatchdog(threading.Thread):
|
||||
now = datetime.datetime.now().timestamp()
|
||||
|
||||
for name, detector in self.detectors.items():
|
||||
detection_start = detector.detection_start.value # type: ignore[attr-defined]
|
||||
detection_start = detector.detection_start.value
|
||||
# issue https://github.com/python/typeshed/issues/8799
|
||||
# from mypy 0.981 onwards
|
||||
if detection_start > 0.0 and now - detection_start > 10:
|
||||
|
||||
+32
-25
@@ -136,33 +136,36 @@ ACCESS_NOTES = {
|
||||
}
|
||||
|
||||
|
||||
# Mirrors the router set wired up in frigate.api.fastapi_app.
|
||||
ROUTERS = [
|
||||
auth.router,
|
||||
camera.router,
|
||||
chat.router,
|
||||
classification.router,
|
||||
review.router,
|
||||
main_app.router,
|
||||
preview.router,
|
||||
notification.router,
|
||||
export.router,
|
||||
hardware.router,
|
||||
notices.router,
|
||||
event.router,
|
||||
media.router,
|
||||
motion_search.router,
|
||||
record.router,
|
||||
debug_replay.router,
|
||||
]
|
||||
|
||||
|
||||
def build_app() -> FastAPI:
|
||||
"""Build a bare app with every router mounted.
|
||||
|
||||
This mirrors the router set wired up in frigate.api.fastapi_app. It omits
|
||||
the global admin dependency and all runtime state; the OpenAPI route table
|
||||
and the per-route dependencies are all we need to export and classify.
|
||||
It omits the global admin dependency and all runtime state; the OpenAPI
|
||||
route table and the per-route dependencies are all we need to export and
|
||||
classify.
|
||||
"""
|
||||
app = FastAPI()
|
||||
routers = [
|
||||
auth.router,
|
||||
camera.router,
|
||||
chat.router,
|
||||
classification.router,
|
||||
review.router,
|
||||
main_app.router,
|
||||
preview.router,
|
||||
notification.router,
|
||||
export.router,
|
||||
hardware.router,
|
||||
notices.router,
|
||||
event.router,
|
||||
media.router,
|
||||
motion_search.router,
|
||||
record.router,
|
||||
debug_replay.router,
|
||||
]
|
||||
for router in routers:
|
||||
for router in ROUTERS:
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
@@ -318,13 +321,17 @@ def _classify_base(
|
||||
|
||||
|
||||
def build_access_map(
|
||||
app: FastAPI,
|
||||
exempt_paths: set[str],
|
||||
exempt_prefixes: tuple[str, ...],
|
||||
) -> dict[tuple[str, str], dict]:
|
||||
"""Map (path, lowercase method) -> classification details."""
|
||||
access_map: dict[tuple[str, str], dict] = {}
|
||||
for route in app.routes:
|
||||
|
||||
# app.routes holds opaque wrappers for included routers on newer FastAPI.
|
||||
# The routers mount without a prefix, so their own routes carry final paths.
|
||||
routes = [route for router in ROUTERS for route in router.routes]
|
||||
|
||||
for route in routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
level, roles, flag = classify_route(route, exempt_paths, exempt_prefixes)
|
||||
@@ -515,7 +522,7 @@ def render(spec: dict) -> str:
|
||||
def build_spec() -> tuple[dict, dict, list, list, list]:
|
||||
app = build_app()
|
||||
exempt_paths, exempt_prefixes = read_exempt_rules()
|
||||
access_map = build_access_map(app, exempt_paths, exempt_prefixes)
|
||||
access_map = build_access_map(exempt_paths, exempt_prefixes)
|
||||
|
||||
spec = base_document(app.openapi())
|
||||
normalized = strip_volatile_defaults(spec)
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F"]
|
||||
ignore = ["E501","E711","E712","UP031","UP032","UP042","G004"]
|
||||
extend-select = ["I", "UP", "G", "ASYNC210", "B904"]
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Add-camera wizard - Reolink stream selection with the brand template.
|
||||
*
|
||||
* The wizard asks the camera for its resolution, then probes http-flv first
|
||||
* above 5MP and falls back to RTSP. The Step 4 RTSP warning is only for
|
||||
* cameras that should be on http-flv. An http-flv stream the wizard selects
|
||||
* is registered with go2rtc through the ffmpeg module.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const FLV_PATH = "channel0_main.bcs";
|
||||
const RTSP_PATH = "Preview_01_main";
|
||||
const RTSP_WARNING = "Reolink RTSP is not recommended";
|
||||
const HTTP_WARNING = "Reolink HTTP streams should use FFmpeg";
|
||||
|
||||
const FFPROBE_OK = [
|
||||
{
|
||||
return_code: 0,
|
||||
stderr: [],
|
||||
stdout: {
|
||||
streams: [
|
||||
{
|
||||
codec_type: "video",
|
||||
codec_name: "hevc",
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
avg_frame_rate: "15/1",
|
||||
},
|
||||
{ codec_type: "audio", codec_name: "aac" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const FFPROBE_FAILED = [
|
||||
{ return_code: 1, stderr: ["probe failed"], stdout: "" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Mock the camera's answers and drive the wizard to Step 3. Returns the
|
||||
* dialog and the stream paths the wizard probed, in order.
|
||||
*/
|
||||
async function gotoStep3(
|
||||
page: Page,
|
||||
{ protocol, flvProbes }: { protocol: string | null; flvProbes: boolean },
|
||||
) {
|
||||
const probed: string[] = [];
|
||||
|
||||
await page.route("**/api/reolink/detect**", (route) =>
|
||||
route.fulfill({ json: { success: protocol !== null, protocol } }),
|
||||
);
|
||||
await page.route("**/api/ffprobe**", (route) => {
|
||||
const paths = new URL(route.request().url()).searchParams.get("paths");
|
||||
const isFlv = !!paths?.includes(FLV_PATH);
|
||||
probed.push(isFlv ? FLV_PATH : RTSP_PATH);
|
||||
return route.fulfill({
|
||||
json: isFlv && !flvProbes ? FFPROBE_FAILED : FFPROBE_OK,
|
||||
});
|
||||
});
|
||||
await page.route("**/api/ffprobe/snapshot**", (route) =>
|
||||
route.fulfill({ status: 500 }),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: /Add New Camera/i }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByPlaceholder(/front_door/i).fill("reolink_test_camera");
|
||||
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
|
||||
await dialog.getByPlaceholder("Optional").first().fill("admin");
|
||||
await dialog.getByPlaceholder("Optional").last().fill("pw");
|
||||
await dialog.getByText("Manual selection").click();
|
||||
await dialog.getByRole("combobox").click();
|
||||
await page.getByRole("option", { name: "Reolink" }).click();
|
||||
await dialog.getByRole("button", { name: /^Continue$/i }).click();
|
||||
|
||||
// Step 2 tests the connection on its own, then offers Continue
|
||||
const next = dialog.getByRole("button", { name: /^Continue$/i });
|
||||
await expect(next).toBeEnabled({ timeout: 10_000 });
|
||||
await next.click();
|
||||
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /Add Another Stream/i }),
|
||||
).toBeVisible();
|
||||
return { dialog, probed };
|
||||
}
|
||||
|
||||
test.describe("Camera wizard Reolink stream selection @medium @mobile", () => {
|
||||
test.beforeEach(async ({ frigateApp }) => {
|
||||
// not in the default mock; unmocked it 500s and trips the error collector
|
||||
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: {} }),
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraManagement");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("above 5MP keeps http-flv when it probes", async ({ frigateApp }) => {
|
||||
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||
protocol: "rtsp",
|
||||
flvProbes: true,
|
||||
});
|
||||
|
||||
expect(probed).toEqual([FLV_PATH]);
|
||||
await expect(dialog.locator(`input[value*="${FLV_PATH}"]`)).toBeVisible();
|
||||
|
||||
const registered: string[] = [];
|
||||
await frigateApp.page.route("**/api/go2rtc/streams/**", (route) => {
|
||||
const src = new URL(route.request().url()).searchParams.get("src");
|
||||
if (src) registered.push(src);
|
||||
return route.fulfill({ json: {} });
|
||||
});
|
||||
|
||||
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /Save New Camera/i }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => registered[0]).toMatch(/^ffmpeg:http:\/\//);
|
||||
await expect(dialog.getByText(HTTP_WARNING)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("above 5MP falls back to RTSP without a warning", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||
protocol: "rtsp",
|
||||
flvProbes: false,
|
||||
});
|
||||
|
||||
expect(probed).toEqual([FLV_PATH, RTSP_PATH]);
|
||||
await expect(dialog.locator(`input[value*="${RTSP_PATH}"]`)).toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: /Save New Camera/i }),
|
||||
).toBeVisible();
|
||||
await expect(dialog.getByText(RTSP_WARNING)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("failed detection uses RTSP and warns", async ({ frigateApp }) => {
|
||||
const { dialog, probed } = await gotoStep3(frigateApp.page, {
|
||||
protocol: null,
|
||||
flvProbes: true,
|
||||
});
|
||||
|
||||
expect(probed).toEqual([RTSP_PATH]);
|
||||
|
||||
await dialog.getByRole("button", { name: /^Next$/i }).click();
|
||||
await expect(dialog.getByText(RTSP_WARNING)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -14,17 +14,6 @@ const NOW = Math.floor(Date.now() / 1000);
|
||||
// the fixture detector runs at 75.5 ms, above the live warning threshold
|
||||
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
|
||||
|
||||
// the fixture has no go2rtc streams, which gives every camera a live view hint
|
||||
const RESTREAMED = {
|
||||
go2rtc: {
|
||||
streams: {
|
||||
front_door: ["rtsp://x"],
|
||||
backyard: ["rtsp://x"],
|
||||
garage: ["rtsp://x"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ERROR_NOTICE = {
|
||||
id: "model_download_failed:yolo/model.onnx",
|
||||
kind: "model_download_failed",
|
||||
@@ -62,7 +51,6 @@ test.describe("System — Health tab @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
@@ -96,7 +84,6 @@ test.describe("System — Health tab @medium", () => {
|
||||
for (const action of ["acknowledge", "mute"] as const) {
|
||||
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
@@ -139,10 +126,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
}
|
||||
|
||||
test("empty state with no notices", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
@@ -345,7 +329,6 @@ test.describe("System — Health tab @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
@@ -782,7 +765,6 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
test("status bar problems stay out of the list", async ({ frigateApp }) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: {
|
||||
service: { retention_unmet: true },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
@@ -847,28 +829,6 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test("a camera without a go2rtc stream gets a live view hint", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { go2rtc: { streams: { front_door: ["rtsp://x"] } } },
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-config:live:no-go2rtc-stream:camera.backyard",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toHaveAttribute("data-severity", "info");
|
||||
await expect(row).toContainText("lower frame rate and no audio");
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
"health-problem-config:live:no-go2rtc-stream:camera.front_door",
|
||||
),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a global config problem is not repeated per camera", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -933,10 +893,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
test("empty state when stats, config, and registry are clean", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
@@ -1173,7 +1130,6 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
config: RESTREAMED,
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
|
||||
Generated
+722
-1435
File diff suppressed because it is too large
Load Diff
+52
-52
@@ -23,33 +23,33 @@
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.20",
|
||||
"@radix-ui/react-context-menu": "^2.3.7",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-hover-card": "^1.1.23",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-progress": "^1.1.16",
|
||||
"@radix-ui/react-radio-group": "^1.4.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slider": "^1.4.7",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-toggle": "^1.1.18",
|
||||
"@radix-ui/react-toggle-group": "^1.1.19",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@rjsf/core": "^6.10.0",
|
||||
"@rjsf/shadcn": "^6.10.0",
|
||||
"@rjsf/utils": "^6.10.0",
|
||||
"@rjsf/validator-ajv8": "^6.10.0",
|
||||
"apexcharts": "^7.3.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.24",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.16",
|
||||
"@radix-ui/react-checkbox": "^1.3.12",
|
||||
"@radix-ui/react-collapsible": "^1.1.21",
|
||||
"@radix-ui/react-context-menu": "^2.3.8",
|
||||
"@radix-ui/react-dialog": "^1.2.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.25",
|
||||
"@radix-ui/react-hover-card": "^1.1.24",
|
||||
"@radix-ui/react-label": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.2.0",
|
||||
"@radix-ui/react-progress": "^1.1.17",
|
||||
"@radix-ui/react-radio-group": "^1.4.8",
|
||||
"@radix-ui/react-scroll-area": "^1.3.0",
|
||||
"@radix-ui/react-select": "^2.3.8",
|
||||
"@radix-ui/react-separator": "^1.1.16",
|
||||
"@radix-ui/react-slider": "^1.5.0",
|
||||
"@radix-ui/react-slot": "1.4.0",
|
||||
"@radix-ui/react-switch": "^1.3.8",
|
||||
"@radix-ui/react-tabs": "^1.1.22",
|
||||
"@radix-ui/react-toggle": "^1.1.19",
|
||||
"@radix-ui/react-toggle-group": "^1.1.20",
|
||||
"@radix-ui/react-tooltip": "^1.3.0",
|
||||
"@rjsf/core": "^6.10.1",
|
||||
"@rjsf/shadcn": "^6.10.1",
|
||||
"@rjsf/utils": "^6.10.1",
|
||||
"@rjsf/validator-ajv8": "^6.10.1",
|
||||
"apexcharts": "^7.8.0",
|
||||
"axios": "^1.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -57,31 +57,31 @@
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^13.3.0",
|
||||
"framer-motion": "^14.0.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"i18next": "^26.4.2",
|
||||
"i18next-http-backend": "^4.0.2",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.4.2",
|
||||
"konva": "^10.5.0",
|
||||
"js-yaml": "^5.4.3",
|
||||
"konva": "^10.7.1",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.46.0",
|
||||
"lucide-react": "^1.52.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^19.3.0",
|
||||
"react-apexcharts": "^2.1.1",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-day-picker": "^10.0.2",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-dropzone": "^20.1.2",
|
||||
"react-grid-layout": "^2.2.4",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "^17.0.14",
|
||||
"react-grid-layout": "^2.3.0",
|
||||
"react-hook-form": "^7.89.0",
|
||||
"react-i18next": "^17.0.16",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.2.7",
|
||||
"react-konva": "^19.3.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.30.6",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-zoom-pan-pinch": "3.4.4",
|
||||
"remark-gfm": "^4.0.0",
|
||||
@@ -89,45 +89,45 @@
|
||||
"sonner": "^2.0.8",
|
||||
"swr": "^2.5.1",
|
||||
"tailwind-merge": "^2.4.0",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.51.3",
|
||||
"virtua": "^0.53.3",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@playwright/test": "^1.64.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.25",
|
||||
"@types/node": "^26.5.1",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"autoprefixer": "^10.6.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.12.0",
|
||||
"globals": "^17.13.0",
|
||||
"i18next-cli": "^1.5.11",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.5.12",
|
||||
"prettier": "^3.9.6",
|
||||
"postcss": "^8.5.29",
|
||||
"prettier": "^3.9.9",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^8.3.0"
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.71.1",
|
||||
"vite": "^8.3.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-slot": "1.2.4"
|
||||
"@radix-ui/react-slot": "1.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
diff --git a/node_modules/@radix-ui/react-slot/dist/index.js b/node_modules/@radix-ui/react-slot/dist/index.js
|
||||
index 3691205..3b62ea8 100644
|
||||
--- a/node_modules/@radix-ui/react-slot/dist/index.js
|
||||
+++ b/node_modules/@radix-ui/react-slot/dist/index.js
|
||||
@@ -85,11 +85,12 @@ function createSlotClone(ownerName) {
|
||||
if (isLazyComponent(children) && typeof use === "function") {
|
||||
children = use(children._payload);
|
||||
}
|
||||
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
|
||||
+ const composedRef = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, childrenRef);
|
||||
if (React.isValidElement(children)) {
|
||||
- const childrenRef = getElementRef(children);
|
||||
const props2 = mergeProps(slotProps, children.props);
|
||||
if (children.type !== React.Fragment) {
|
||||
- props2.ref = forwardedRef ? (0, import_react_compose_refs.composeRefs)(forwardedRef, childrenRef) : childrenRef;
|
||||
+ props2.ref = forwardedRef ? composedRef : childrenRef;
|
||||
}
|
||||
return React.cloneElement(children, props2);
|
||||
}
|
||||
diff --git a/node_modules/@radix-ui/react-slot/dist/index.mjs b/node_modules/@radix-ui/react-slot/dist/index.mjs
|
||||
index d7ea374..a990150 100644
|
||||
--- a/node_modules/@radix-ui/react-slot/dist/index.mjs
|
||||
+++ b/node_modules/@radix-ui/react-slot/dist/index.mjs
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/slot.tsx
|
||||
import * as React from "react";
|
||||
-import { composeRefs } from "@radix-ui/react-compose-refs";
|
||||
+import { composeRefs, useComposedRefs } from "@radix-ui/react-compose-refs";
|
||||
import { Fragment as Fragment2, jsx } from "react/jsx-runtime";
|
||||
var REACT_LAZY_TYPE = Symbol.for("react.lazy");
|
||||
var use = React[" use ".trim().toString()];
|
||||
@@ -45,11 +45,12 @@ function createSlotClone(ownerName) {
|
||||
if (isLazyComponent(children) && typeof use === "function") {
|
||||
children = use(children._payload);
|
||||
}
|
||||
+ const childrenRef = React.isValidElement(children) ? getElementRef(children) : null;
|
||||
+ const composedRef = useComposedRefs(forwardedRef, childrenRef);
|
||||
if (React.isValidElement(children)) {
|
||||
- const childrenRef = getElementRef(children);
|
||||
const props2 = mergeProps(slotProps, children.props);
|
||||
if (children.type !== React.Fragment) {
|
||||
- props2.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
|
||||
+ props2.ref = forwardedRef ? composedRef : childrenRef;
|
||||
}
|
||||
return React.cloneElement(children, props2);
|
||||
}
|
||||
@@ -1997,9 +1997,6 @@
|
||||
"genaiImageSourceRecordingsRecordDisabled": "Image source is set to 'recordings', but recording is disabled. Frigate will fall back to preview images.",
|
||||
"genaiImageSourceRecordingsRecordRuntimeDisabled": "Image source is set to 'recordings', but recording is currently turned off for this camera even though your config enables it. Frigate will fall back to preview images."
|
||||
},
|
||||
"live": {
|
||||
"noGo2rtcStream": "Live view for this camera is using a basic player with a lower frame rate and no audio. Set up a go2rtc stream for this camera to get smoother video and audio."
|
||||
},
|
||||
"audio": {
|
||||
"noAudioRole": "No streams have the audio role defined. You must enable the audio role for audio detection to function."
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"ffmpeg_high_cpu": "FFmpeg CPU usage is high ({{cpu}}% average)",
|
||||
"detect_high_cpu": "Detection CPU usage is high ({{cpu}}% average)",
|
||||
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
|
||||
"object_processing_behind": "Tracked object processing could not keep up, so camera frames were dropped. Check CPU utilization, and whether CPU limits or pinning on the container or VM are leaving Frigate too little CPU",
|
||||
"failed_login_one": "Failed login attempt for {{user}}",
|
||||
"failed_login_other": "Failed login attempts for {{user}}",
|
||||
"update_available": "Frigate {{version}} is available"
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<Providers>
|
||||
<BrowserRouter basename={window.baseUrl}>
|
||||
<BrowserRouter basename={window.baseUrl} useTransitions={false}>
|
||||
<Wrapper>
|
||||
{config?.safe_mode ? <SafeAppView /> : <DefaultAppView />}
|
||||
</Wrapper>
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
import { isRestreamedStream } from "@/utils/liveTranscode";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
const live: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/live",
|
||||
messages: [
|
||||
{
|
||||
key: "no-go2rtc-stream",
|
||||
health: true,
|
||||
messageKey: "configMessages.live.noGo2rtcStream",
|
||||
severity: "info",
|
||||
docLink: "/configuration/live",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
return !Object.values(ctx.fullCameraConfig.live.streams).some(
|
||||
(name) => isRestreamedStream(ctx.fullConfig, name),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
restartRequired: [],
|
||||
fieldOrder: ["streams", "transcode", "height", "quality"],
|
||||
fieldGroups: {},
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
hardwareForDevices,
|
||||
MAX_DETECTORS,
|
||||
recommendedDetectorCount,
|
||||
resolveUnitDevice,
|
||||
} from "@/utils/detectionHardware";
|
||||
|
||||
type HardwarePickerProps = {
|
||||
@@ -61,13 +60,9 @@ export function HardwarePicker({
|
||||
return [];
|
||||
}
|
||||
|
||||
const assigned = devices.map((device) =>
|
||||
resolveUnitDevice(selected, device),
|
||||
);
|
||||
|
||||
return selected.units
|
||||
.map((unit) => unit.device)
|
||||
.filter((device) => assigned.includes(device));
|
||||
.filter((device) => devices.includes(device));
|
||||
}, [selected, devices]);
|
||||
|
||||
/** Spread `count` detectors round robin over the selected units. */
|
||||
@@ -205,7 +200,7 @@ export function HardwarePicker({
|
||||
<Checkbox
|
||||
id={`${idPrefix}-${unit.device}`}
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={selectedUnits.includes(unit.device)}
|
||||
checked={devices.includes(unit.device)}
|
||||
disabled={disabled || Boolean(claimedBy)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUnitToggle(unit.device, checked === true)
|
||||
|
||||
@@ -155,7 +155,7 @@ export function CombinedStorageGraph({
|
||||
formatter: function (val, opts) {
|
||||
const entry = opts ? series[opts.seriesIndex] : undefined;
|
||||
if (entry) {
|
||||
return `${getUnitSize(entry.usage)} (${val.toFixed(2)}%)`;
|
||||
return `${getUnitSize(entry.usage)} (${(val ?? 0).toFixed(2)}%)`;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -222,6 +222,7 @@ export default function Step2ProbeOrSnapshot({
|
||||
wizardData.username,
|
||||
wizardData.password,
|
||||
);
|
||||
update.reolinkProtocol = protocol;
|
||||
if (protocol === "http-flv") {
|
||||
update.brandTemplate = "reolink";
|
||||
}
|
||||
@@ -294,73 +295,72 @@ export default function Step2ProbeOrSnapshot({
|
||||
[probeUri],
|
||||
);
|
||||
|
||||
const generateDynamicStreamUrl = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string | null> => {
|
||||
const generateDynamicStreamUrls = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string[]> => {
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return null;
|
||||
const host = data.host;
|
||||
if (!brand || !host) return [];
|
||||
|
||||
let protocol = undefined;
|
||||
if (data.brandTemplate === "reolink" && data.username && data.password) {
|
||||
try {
|
||||
protocol = await detectReolinkCamera(
|
||||
data.host,
|
||||
host,
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
onUpdate({ reolinkProtocol: protocol });
|
||||
}
|
||||
|
||||
const protocolKey = protocol || "rtsp";
|
||||
// Only some Reolink cameras above 5MP serve http-flv, so RTSP is the
|
||||
// fallback when the http-flv stream does not probe.
|
||||
const protocolKeys =
|
||||
protocol === "rtsp" ? ["http-flv", "rtsp"] : [protocol || "rtsp"];
|
||||
const templates: Record<string, string> = brand.dynamicTemplates || {};
|
||||
|
||||
if (Object.keys(templates).includes(protocolKey)) {
|
||||
const template =
|
||||
templates[protocolKey as keyof typeof brand.dynamicTemplates];
|
||||
return template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
}
|
||||
|
||||
return null;
|
||||
return protocolKeys
|
||||
.filter((key) => key in templates)
|
||||
.map((key) =>
|
||||
templates[key]
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", host),
|
||||
);
|
||||
},
|
||||
[],
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const generateStreamUrl = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string> => {
|
||||
const generateStreamUrls = useCallback(
|
||||
async (data: Partial<WizardFormData>): Promise<string[]> => {
|
||||
if (data.brandTemplate === "other") {
|
||||
return data.customUrl || "";
|
||||
return data.customUrl ? [data.customUrl] : [];
|
||||
}
|
||||
|
||||
const brand = CAMERA_BRANDS.find((b) => b.value === data.brandTemplate);
|
||||
if (!brand || !data.host) return "";
|
||||
if (!brand || !data.host) return [];
|
||||
|
||||
if (brand.template === "dynamic" && "dynamicTemplates" in brand) {
|
||||
const dynamicUrl = await generateDynamicStreamUrl(data);
|
||||
|
||||
if (dynamicUrl) {
|
||||
return dynamicUrl;
|
||||
}
|
||||
|
||||
return "";
|
||||
return generateDynamicStreamUrls(data);
|
||||
}
|
||||
|
||||
return brand.template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host);
|
||||
return [
|
||||
brand.template
|
||||
.replace("{username}", data.username || "")
|
||||
.replace("{password}", data.password || "")
|
||||
.replace("{host}", data.host),
|
||||
];
|
||||
},
|
||||
[generateDynamicStreamUrl],
|
||||
[generateDynamicStreamUrls],
|
||||
);
|
||||
|
||||
const testConnection = useCallback(
|
||||
async (showToast = true) => {
|
||||
const streamUrl = await generateStreamUrl(wizardData);
|
||||
const streamUrls = await generateStreamUrls(wizardData);
|
||||
|
||||
if (!streamUrl) {
|
||||
if (streamUrls.length === 0) {
|
||||
toast.error(t("cameraWizard.commonErrors.noUrl"));
|
||||
return;
|
||||
}
|
||||
@@ -370,8 +370,18 @@ export default function Step2ProbeOrSnapshot({
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||
const result = await probeUri(streamUrl, true, setTestStatus);
|
||||
let streamUrl = streamUrls[0];
|
||||
let result: TestResult | undefined;
|
||||
|
||||
for (const url of streamUrls) {
|
||||
streamUrl = url;
|
||||
setTestStatus(t("cameraWizard.step2.testing.probingMetadata"));
|
||||
result = await probeUri(url, true, setTestStatus);
|
||||
|
||||
if (result.success && result.resolution) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
setTestResult(result);
|
||||
@@ -383,6 +393,9 @@ export default function Step2ProbeOrSnapshot({
|
||||
url: streamUrl,
|
||||
roles: ["detect"] as StreamRole[],
|
||||
testResult: result,
|
||||
useFfmpeg:
|
||||
wizardData.brandTemplate === "reolink" &&
|
||||
streamUrl.startsWith("http://"),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -434,7 +447,7 @@ export default function Step2ProbeOrSnapshot({
|
||||
setTestStatus("");
|
||||
}
|
||||
},
|
||||
[wizardData, generateStreamUrl, t, onUpdate, probeUri],
|
||||
[wizardData, generateStreamUrls, t, onUpdate, probeUri],
|
||||
);
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
|
||||
@@ -499,6 +499,7 @@ function StreamIssues({
|
||||
url: stream.url,
|
||||
roles: stream.roles,
|
||||
brand: wizardData.brandTemplate,
|
||||
reolinkProtocol: wizardData.reolinkProtocol,
|
||||
useFfmpeg: stream.useFfmpeg,
|
||||
restream: stream.restream,
|
||||
testResult: stream.testResult,
|
||||
|
||||
@@ -110,6 +110,7 @@ export type WizardFormData = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
brandTemplate?: CameraBrand;
|
||||
reolinkProtocol?: "http-flv" | "rtsp" | null; // null when detection failed
|
||||
customUrl?: string;
|
||||
streams?: StreamConfig[];
|
||||
probeMode?: boolean; // true for probe, false for manual
|
||||
|
||||
@@ -21,31 +21,10 @@ export function hardwareForDevices(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hardware.find((entry) =>
|
||||
devices.every((device) => resolveUnitDevice(entry, device)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit device string a configured device refers to.
|
||||
*
|
||||
* A config may leave the index off, such as "edgetpu:usb", which the detector
|
||||
* resolves to the first unit of that kind.
|
||||
*/
|
||||
export function resolveUnitDevice(
|
||||
entry: DetectionHardware,
|
||||
device: string,
|
||||
): string | undefined {
|
||||
const unitDevices = entry.units.map((unit) => unit.device);
|
||||
|
||||
return (
|
||||
unitDevices.find((candidate) => candidate === device) ??
|
||||
unitDevices.find(
|
||||
(candidate) =>
|
||||
candidate.startsWith(`${device}:`) ||
|
||||
candidate.startsWith(`${device}.`),
|
||||
)
|
||||
);
|
||||
return hardware.find((entry) => {
|
||||
const known = new Set(entry.units.map((unit) => unit.device));
|
||||
return devices.every((device) => known.has(device));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ export type StreamIssueInput = {
|
||||
url: string;
|
||||
roles: StreamRole[];
|
||||
brand?: CameraBrand;
|
||||
reolinkProtocol?: "http-flv" | "rtsp" | null;
|
||||
useFfmpeg?: boolean;
|
||||
restream?: boolean;
|
||||
testResult?: TestResult;
|
||||
@@ -96,7 +97,7 @@ export function getStreamIssues(
|
||||
|
||||
if (input.brand === "reolink") {
|
||||
const streamUrl = input.url.toLowerCase();
|
||||
if (streamUrl.startsWith("rtsp://")) {
|
||||
if (streamUrl.startsWith("rtsp://") && input.reolinkProtocol !== "rtsp") {
|
||||
result.push({
|
||||
type: "warning",
|
||||
rule: "reolink-rtsp",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user