mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-27 18:48:22 +03:00
Compare commits
5 Commits
7407fbe308
...
100df07d05
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
100df07d05 | ||
|
|
a93b3e329c | ||
|
|
fb47ca00f1 | ||
|
|
631f9c6ffd | ||
|
|
3afdd2aedd |
@ -266,12 +266,6 @@ RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||
RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
|
||||
pip3 install -U /deps/wheels/*.whl
|
||||
|
||||
# Install Axera Engine
|
||||
RUN pip3 install https://github.com/AXERA-TECH/pyaxengine/releases/download/0.1.3-frigate/axengine-0.1.3-py3-none-any.whl
|
||||
|
||||
ENV PATH="${PATH}:/usr/bin/axcl"
|
||||
ENV LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:/usr/lib/axcl"
|
||||
|
||||
# Install MemryX runtime (requires libgomp (OpenMP) in the final docker image)
|
||||
RUN --mount=type=bind,source=docker/main/install_memryx.sh,target=/deps/install_memryx.sh \
|
||||
bash -c "bash /deps/install_memryx.sh"
|
||||
|
||||
@ -73,7 +73,6 @@ cd /tmp/nginx
|
||||
--with-file-aio \
|
||||
--with-http_sub_module \
|
||||
--with-http_ssl_module \
|
||||
--with-http_v2_module \
|
||||
--with-http_auth_request_module \
|
||||
--with-http_realip_module \
|
||||
--with-threads \
|
||||
|
||||
@ -63,9 +63,6 @@ http {
|
||||
server {
|
||||
include listen.conf;
|
||||
|
||||
# enable HTTP/2 for TLS connections to eliminate browser 6-connection limit
|
||||
http2 on;
|
||||
|
||||
# vod settings
|
||||
vod_base_url '';
|
||||
vod_segments_base_url '';
|
||||
|
||||
@ -49,11 +49,6 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs.
|
||||
|
||||
**AXERA** <CommunityBadge />
|
||||
|
||||
- [AXEngine](#axera): axmodels can run on AXERA AI acceleration.
|
||||
|
||||
|
||||
**For Testing**
|
||||
|
||||
- [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results.
|
||||
@ -1483,41 +1478,6 @@ model:
|
||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
||||
```
|
||||
|
||||
## AXERA
|
||||
|
||||
Hardware accelerated object detection is supported on the following SoCs:
|
||||
|
||||
- AX650N
|
||||
- AX8850N
|
||||
|
||||
This implementation uses the [AXera Pulsar2 Toolchain](https://huggingface.co/AXERA-TECH/Pulsar2).
|
||||
|
||||
See the [installation docs](../frigate/installation.md#axera) for information on configuring the AXEngine hardware.
|
||||
|
||||
### Configuration
|
||||
|
||||
When configuring the AXEngine detector, you have to specify the model name.
|
||||
|
||||
#### yolov9
|
||||
|
||||
A yolov9 model is provided in the container at `/axmodels` and is used by this detector type by default.
|
||||
|
||||
Use the model configuration shown below when using the axengine detector with the default axmodel:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
axengine:
|
||||
type: axengine
|
||||
|
||||
model:
|
||||
path: frigate-yolov9-tiny
|
||||
model_type: yolo-generic
|
||||
width: 320
|
||||
height: 320
|
||||
tensor_format: bgr
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
```
|
||||
|
||||
# Models
|
||||
|
||||
Some model types are not included in Frigate by default.
|
||||
@ -1611,12 +1571,12 @@ YOLOv9 model can be exported as ONNX using the command below. You can copy and p
|
||||
```sh
|
||||
docker build . --build-arg MODEL_SIZE=t --build-arg IMG_SIZE=320 --output . -f- <<'EOF'
|
||||
FROM python:3.11 AS build
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y cmake libgl1 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
|
||||
WORKDIR /yolov9
|
||||
ADD https://github.com/WongKinYiu/yolov9.git .
|
||||
RUN uv pip install --system -r requirements.txt
|
||||
RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* onnxscript
|
||||
RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier>=0.4.1 onnxscript
|
||||
ARG MODEL_SIZE
|
||||
ARG IMG_SIZE
|
||||
ADD https://github.com/WongKinYiu/yolov9/releases/download/v0.1/yolov9-${MODEL_SIZE}-converted.pt yolov9-${MODEL_SIZE}.pt
|
||||
|
||||
@ -103,10 +103,6 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs to provide efficient object detection.
|
||||
|
||||
**AXERA** <CommunityBadge />
|
||||
|
||||
- [AXEngine](#axera): axera models can run on AXERA NPUs via AXEngine, delivering highly efficient object detection.
|
||||
|
||||
:::
|
||||
|
||||
### Hailo-8
|
||||
@ -292,14 +288,6 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
|
||||
| ssd mobilenet | ~ 25 ms |
|
||||
| yolov5m | ~ 118 ms |
|
||||
|
||||
### AXERA
|
||||
|
||||
- **AXEngine** Default model is **yolov9**
|
||||
|
||||
| Name | AXERA AX650N/AX8850N Inference Time |
|
||||
| ---------------- | ----------------------------------- |
|
||||
| yolov9-tiny | ~ 4 ms |
|
||||
|
||||
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
|
||||
|
||||
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
|
||||
@ -320,4 +308,4 @@ Basically - When you increase the resolution and/or the frame rate of the stream
|
||||
|
||||
YES! The Coral does not help with decoding video streams.
|
||||
|
||||
Decompressing video streams takes a significant amount of CPU power. Video compression uses key frames (also known as I-frames) to send a full frame in the video stream. The following frames only include the difference from the key frame, and the CPU has to compile each frame by merging the differences with the key frame. [More detailed explanation](https://support.video.ibm.com/hc/en-us/articles/18106203580316-Keyframes-InterFrame-Video-Compression). Higher resolutions and frame rates mean more processing power is needed to decode the video stream, so try and set them on the camera to avoid unnecessary decoding work.
|
||||
Decompressing video streams takes a significant amount of CPU power. Video compression uses key frames (also known as I-frames) to send a full frame in the video stream. The following frames only include the difference from the key frame, and the CPU has to compile each frame by merging the differences with the key frame. [More detailed explanation](https://support.video.ibm.com/hc/en-us/articles/18106203580316-Keyframes-InterFrame-Video-Compression). Higher resolutions and frame rates mean more processing power is needed to decode the video stream, so try and set them on the camera to avoid unnecessary decoding work.
|
||||
|
||||
@ -439,39 +439,6 @@ or add these options to your `docker run` command:
|
||||
|
||||
Next, you should configure [hardware object detection](/configuration/object_detectors#synaptics) and [hardware video processing](/configuration/hardware_acceleration_video#synaptics).
|
||||
|
||||
### AXERA
|
||||
|
||||
AXERA accelerators are available in an M.2 form factor, compatible with both Raspberry Pi and Orange Pi. This form factor has also been successfully tested on x86 platforms, making it a versatile choice for various computing environments.
|
||||
|
||||
#### Installation
|
||||
|
||||
Using AXERA accelerators requires the installation of the AXCL driver. We provide a convenient Linux script to complete this installation.
|
||||
|
||||
Follow these steps for installation:
|
||||
|
||||
1. Copy or download [this script](https://github.com/ivanshi1108/assets/releases/download/v0.16.2/user_installation.sh).
|
||||
2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
|
||||
3. Run the script with `./user_installation.sh`
|
||||
|
||||
#### Setup
|
||||
|
||||
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||
|
||||
Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
devices:
|
||||
- /dev/axcl_host
|
||||
- /dev/ax_mmb_dev
|
||||
- /dev/msg_userdev
|
||||
```
|
||||
|
||||
If you are using `docker run`, add this option to your command `--device /dev/axcl_host --device /dev/ax_mmb_dev --device /dev/msg_userdev`
|
||||
|
||||
#### Configuration
|
||||
|
||||
Finally, configure [hardware object detection](/configuration/object_detectors#axera) to complete the setup.
|
||||
|
||||
## Docker
|
||||
|
||||
Running through Docker with Docker Compose is the recommended install method.
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
import logging
|
||||
import os.path
|
||||
import re
|
||||
import urllib.request
|
||||
from typing import Literal
|
||||
|
||||
import axengine as axe
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
|
||||
from frigate.util.model import post_process_yolo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DETECTOR_KEY = "axengine"
|
||||
|
||||
supported_models = {
|
||||
ModelTypeEnum.yologeneric: "frigate-yolov9-.*$",
|
||||
}
|
||||
|
||||
model_cache_dir = os.path.join(MODEL_CACHE_DIR, "axengine_cache/")
|
||||
|
||||
|
||||
class AxengineDetectorConfig(BaseDetectorConfig):
|
||||
type: Literal[DETECTOR_KEY]
|
||||
|
||||
|
||||
class Axengine(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
|
||||
def __init__(self, config: AxengineDetectorConfig):
|
||||
logger.info("__init__ axengine")
|
||||
super().__init__(config)
|
||||
self.height = config.model.height
|
||||
self.width = config.model.width
|
||||
model_path = config.model.path or "frigate-yolov9-tiny"
|
||||
model_props = self.parse_model_input(model_path)
|
||||
self.session = axe.InferenceSession(model_props["path"])
|
||||
|
||||
def __del__(self):
|
||||
pass
|
||||
|
||||
def parse_model_input(self, model_path):
|
||||
model_props = {}
|
||||
model_props["preset"] = True
|
||||
|
||||
model_matched = False
|
||||
|
||||
for model_type, pattern in supported_models.items():
|
||||
if re.match(pattern, model_path):
|
||||
model_matched = True
|
||||
model_props["model_type"] = model_type
|
||||
|
||||
if model_matched:
|
||||
model_props["filename"] = model_path + ".axmodel"
|
||||
model_props["path"] = model_cache_dir + model_props["filename"]
|
||||
|
||||
if not os.path.isfile(model_props["path"]):
|
||||
self.download_model(model_props["filename"])
|
||||
else:
|
||||
supported_models_str = ", ".join(model[1:-1] for model in supported_models)
|
||||
raise Exception(
|
||||
f"Model {model_path} is unsupported. Provide your own model or choose one of the following: {supported_models_str}"
|
||||
)
|
||||
return model_props
|
||||
|
||||
def download_model(self, filename):
|
||||
if not os.path.isdir(model_cache_dir):
|
||||
os.mkdir(model_cache_dir)
|
||||
|
||||
HF_ENDPOINT = os.environ.get("HF_ENDPOINT", "https://huggingface.co")
|
||||
urllib.request.urlretrieve(
|
||||
f"{HF_ENDPOINT}/AXERA-TECH/frigate-resource/resolve/axmodel/{filename}",
|
||||
model_cache_dir + filename,
|
||||
)
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
results = None
|
||||
results = self.session.run(None, {"images": tensor_input})
|
||||
if self.detector_config.model.model_type == ModelTypeEnum.yologeneric:
|
||||
return post_process_yolo(results, self.width, self.height)
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Model type "{self.detector_config.model.model_type}" is currently not supported.'
|
||||
)
|
||||
72
web/package-lock.json
generated
72
web/package-lock.json
generated
@ -72,6 +72,8 @@
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-tracked": "^2.0.1",
|
||||
"react-use-websocket": "^4.8.1",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
@ -4398,6 +4400,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-LTjFz5Fk3FlbgFPJ+OJi1JdWJyiap9dSpx8W6u7JHNB7K5VbwzJe8gIU45XWLHzWFGDHKPm89VrUzjOs07TPtg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.23",
|
||||
"lodash-es": "^4.17.23",
|
||||
@ -4472,6 +4475,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.3.1.tgz",
|
||||
"integrity": "sha512-ve2KHl1ITYG8QIonnuK83/T1k/5NuxP4D1egVqP9Hz2ub28kgl0rNMwmRSxXs3WIbCcMW9g3ox+daVrbSNc4Mw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@x0k/json-schema-merge": "^1.0.2",
|
||||
"fast-uri": "^3.1.0",
|
||||
@ -5145,6 +5149,7 @@
|
||||
"integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
@ -5154,6 +5159,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@ -5164,6 +5170,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@ -5293,6 +5300,7 @@
|
||||
"integrity": "sha512-dm/J2UDY3oV3TKius2OUZIFHsomQmpHtsV0FTh1WO8EKgHLQ1QCADUqscPgTpU+ih1e21FQSRjXckHn3txn6kQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "7.12.0",
|
||||
"@typescript-eslint/types": "7.12.0",
|
||||
@ -5585,6 +5593,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
||||
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -5746,6 +5755,7 @@
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.52.0.tgz",
|
||||
"integrity": "sha512-7dg0ADKs8AA89iYMZMe2sFDG0XK5PfqllKV9N+i3hKHm3vEtdhwz8AlXGm+/b0nJ6jKiaXsqci5LfVxNhtB+dA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@yr/monotone-cubic-spline": "^1.0.3",
|
||||
"svg.draggable.js": "^2.2.2",
|
||||
@ -5956,6 +5966,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001646",
|
||||
"electron-to-chromium": "^1.5.4",
|
||||
@ -6456,6 +6467,7 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@ -6895,6 +6907,7 @@
|
||||
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@ -6950,6 +6963,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
|
||||
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@ -7873,6 +7887,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
},
|
||||
@ -8518,7 +8533,8 @@
|
||||
"url": "https://github.com/sponsors/lavrton"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
@ -9667,7 +9683,8 @@
|
||||
"version": "0.52.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
|
||||
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/monaco-languageserver-types": {
|
||||
"version": "0.4.0",
|
||||
@ -10375,6 +10392,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.8",
|
||||
"picocolors": "^1.1.1",
|
||||
@ -10509,6 +10527,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
|
||||
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@ -10657,6 +10676,11 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-compare": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz",
|
||||
"integrity": "sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w=="
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@ -10709,6 +10733,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -10773,6 +10798,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@ -10834,6 +10860,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.52.1.tgz",
|
||||
"integrity": "sha512-uNKIhaoICJ5KQALYZ4TOaOLElyM+xipord+Ha3crEFhTntdLvWZqVY49Wqd/0GiVCA/f9NjemLeiNPjG7Hpurg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
@ -11088,6 +11115,29 @@
|
||||
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/react-tracked": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-2.0.1.tgz",
|
||||
"integrity": "sha512-qjbmtkO2IcW+rB2cFskRWDTjKs/w9poxvNnduacjQA04LWxOoLy9J8WfIEq1ahifQ/tVJQECrQPBm+UEzKRDtg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"proxy-compare": "^3.0.0",
|
||||
"use-context-selector": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"scheduler": ">=0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-use-websocket": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.8.1.tgz",
|
||||
"integrity": "sha512-FTXuG5O+LFozmu1BRfrzl7UIQngECvGJmL7BHsK4TYXuVt+mCizVA8lT0hGSIF0Z0TedF7bOo1nRzOUdginhDw==",
|
||||
"peerDependencies": {
|
||||
"react": ">= 18.0.0",
|
||||
"react-dom": ">= 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-zoom-pan-pinch": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz",
|
||||
@ -11499,7 +11549,8 @@
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/scroll-into-view-if-needed": {
|
||||
"version": "3.1.0",
|
||||
@ -11998,6 +12049,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz",
|
||||
"integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@ -12180,6 +12232,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -12358,6 +12411,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -12573,6 +12627,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-context-selector": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-2.0.0.tgz",
|
||||
"integrity": "sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==",
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"scheduler": ">=0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-long-press": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.2.0.tgz",
|
||||
@ -12708,6 +12771,7 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@ -12832,6 +12896,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -12845,6 +12910,7 @@
|
||||
"integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "3.0.7",
|
||||
"@vitest/mocker": "3.0.7",
|
||||
|
||||
@ -78,6 +78,8 @@
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-tracked": "^2.0.1",
|
||||
"react-use-websocket": "^4.8.1",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
|
||||
23
web/patches/react-use-websocket+4.8.1.patch
Normal file
23
web/patches/react-use-websocket+4.8.1.patch
Normal file
@ -0,0 +1,23 @@
|
||||
diff --git a/node_modules/react-use-websocket/dist/lib/use-websocket.js b/node_modules/react-use-websocket/dist/lib/use-websocket.js
|
||||
index f01db48..b30aff2 100644
|
||||
--- a/node_modules/react-use-websocket/dist/lib/use-websocket.js
|
||||
+++ b/node_modules/react-use-websocket/dist/lib/use-websocket.js
|
||||
@@ -139,15 +139,15 @@ var useWebSocket = function (url, options, connect) {
|
||||
}
|
||||
protectedSetLastMessage = function (message) {
|
||||
if (!expectClose_1) {
|
||||
- (0, react_dom_1.flushSync)(function () { return setLastMessage(message); });
|
||||
+ setLastMessage(message);
|
||||
}
|
||||
};
|
||||
protectedSetReadyState = function (state) {
|
||||
if (!expectClose_1) {
|
||||
- (0, react_dom_1.flushSync)(function () { return setReadyState(function (prev) {
|
||||
+ setReadyState(function (prev) {
|
||||
var _a;
|
||||
return (__assign(__assign({}, prev), (convertedUrl.current && (_a = {}, _a[convertedUrl.current] = state, _a))));
|
||||
- }); });
|
||||
+ });
|
||||
}
|
||||
};
|
||||
if (createOrJoin_1) {
|
||||
@ -23,17 +23,17 @@
|
||||
"pm": "下午",
|
||||
"am": "上午",
|
||||
"yr": "{{time}}年",
|
||||
"year_other": "{{time}} 年",
|
||||
"year_other": "{{time}}年",
|
||||
"mo": "{{time}}月",
|
||||
"month_other": "{{time}} 个月",
|
||||
"month_other": "{{time}}月",
|
||||
"d": "{{time}}天",
|
||||
"day_other": "{{time}} 天",
|
||||
"day_other": "{{time}}天",
|
||||
"h": "{{time}}小时",
|
||||
"hour_other": "{{time}} 小时",
|
||||
"hour_other": "{{time}}小时",
|
||||
"m": "{{time}}分钟",
|
||||
"minute_other": "{{time}} 分钟",
|
||||
"minute_other": "{{time}}分钟",
|
||||
"s": "{{time}}秒",
|
||||
"second_other": "{{time}} 秒",
|
||||
"second_other": "{{time}}秒",
|
||||
"formattedTimestamp": {
|
||||
"12hour": "M月d日 ah:mm:ss",
|
||||
"24hour": "M月d日 HH:mm:ss"
|
||||
@ -156,18 +156,7 @@
|
||||
"next": "下一个",
|
||||
"cameraAudio": "摄像头音频",
|
||||
"twoWayTalk": "双向对话",
|
||||
"continue": "继续",
|
||||
"add": "添加",
|
||||
"applying": "应用中…",
|
||||
"undo": "撤销",
|
||||
"copiedToClipboard": "已复制到剪贴板",
|
||||
"modified": "已修改",
|
||||
"overridden": "已覆盖",
|
||||
"resetToGlobal": "重置为全局",
|
||||
"resetToDefault": "重置为默认",
|
||||
"saveAll": "保存全部",
|
||||
"savingAll": "保存全部中…",
|
||||
"undoAll": "撤销全部"
|
||||
"continue": "继续"
|
||||
},
|
||||
"menu": {
|
||||
"system": "系统",
|
||||
@ -181,7 +170,7 @@
|
||||
"en": "英语 (English)",
|
||||
"zhCN": "简体中文",
|
||||
"withSystem": {
|
||||
"label": "使用系统的语言设置"
|
||||
"label": "使用系统语言设置"
|
||||
},
|
||||
"hi": "印地语 (हिन्दी)",
|
||||
"es": "西班牙语 (Español)",
|
||||
@ -203,15 +192,15 @@
|
||||
"he": "希伯来语 (עברית)",
|
||||
"el": "希腊语 (Ελληνικά)",
|
||||
"ro": "罗马尼亚语 (Română)",
|
||||
"hu": "匈牙利语 (Magyar)",
|
||||
"hu": "马扎尔语 (Magyar)",
|
||||
"fi": "芬兰语 (Suomi)",
|
||||
"da": "丹麦语 (Dansk)",
|
||||
"sk": "斯洛伐克语 (Slovenčina)",
|
||||
"sk": "斯拉夫语 (Slovenčina)",
|
||||
"ru": "俄语 (Русский)",
|
||||
"cs": "捷克语 (Čeština)",
|
||||
"yue": "粤语 (粵語)",
|
||||
"th": "泰语 (ไทย)",
|
||||
"ca": "加泰罗尼亚语 (Català)",
|
||||
"th": "泰语(ไทย)",
|
||||
"ca": "加泰罗尼亚语 (Català )",
|
||||
"ptBR": "巴西葡萄牙语 (Português brasileiro)",
|
||||
"sr": "塞尔维亚语 (Српски)",
|
||||
"sl": "斯洛文尼亚语 (Slovenščina)",
|
||||
@ -220,7 +209,7 @@
|
||||
"gl": "加利西亚语 (Galego)",
|
||||
"id": "印度尼西亚语 (Bahasa Indonesia)",
|
||||
"ur": "乌尔都语 (اردو)",
|
||||
"hr": "克罗地亚语 (Hrvatski)"
|
||||
"hr": "克罗地亚语(Hrvatski)"
|
||||
},
|
||||
"appearance": "外观",
|
||||
"darkMode": {
|
||||
@ -269,9 +258,7 @@
|
||||
"title": "用户"
|
||||
},
|
||||
"restart": "重启 Frigate",
|
||||
"classification": "目标分类",
|
||||
"actions": "操作",
|
||||
"chat": "聊天"
|
||||
"classification": "目标分类"
|
||||
},
|
||||
"toast": {
|
||||
"copyUrlToClipboard": "已复制链接到剪贴板。",
|
||||
|
||||
@ -64,28 +64,5 @@
|
||||
"normalActivity": "正常",
|
||||
"needsReview": "需要核查",
|
||||
"securityConcern": "安全隐患",
|
||||
"select_all": "所有",
|
||||
"motionSearch": {
|
||||
"menuItem": "画面变动搜索",
|
||||
"openMenu": "摄像头选项"
|
||||
},
|
||||
"motionPreviews": {
|
||||
"menuItem": "查看画面变动预览",
|
||||
"title": "画面变动预览:{{camera}}",
|
||||
"mobileSettingsTitle": "画面变动预览设置",
|
||||
"mobileSettingsDesc": "调整播放速度和变暗程度,并选择日期以仅查看画面变动的片段。",
|
||||
"dim": "变暗",
|
||||
"dimAria": "调整变暗强度",
|
||||
"dimDesc": "增加变暗程度可以提高画面变动区域的可见性。",
|
||||
"speed": "速度",
|
||||
"speedAria": "选择预览播放速度",
|
||||
"speedDesc": "选择预览片段的播放速度。",
|
||||
"back": "返回",
|
||||
"empty": "没有可用的预览",
|
||||
"noPreview": "预览不可用",
|
||||
"seekAria": "将 {{camera}} 播放器定位到 {{time}}",
|
||||
"filter": "筛选",
|
||||
"filterDesc": "选择区域以仅显示在这些区域中有画面变动的片段。",
|
||||
"filterClear": "清除"
|
||||
}
|
||||
"select_all": "所有"
|
||||
}
|
||||
|
||||
@ -11,8 +11,7 @@
|
||||
},
|
||||
"toast": {
|
||||
"error": {
|
||||
"renameExportFailed": "重命名导出失败:{{errorMessage}}",
|
||||
"assignCaseFailed": "更新合集分配失败:{{errorMessage}}"
|
||||
"renameExportFailed": "重命名导出失败:{{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
@ -20,18 +19,13 @@
|
||||
"downloadVideo": "下载视频",
|
||||
"editName": "编辑名称",
|
||||
"deleteExport": "删除导出",
|
||||
"assignToCase": "加入合集"
|
||||
"assignToCase": "加入案例"
|
||||
},
|
||||
"headings": {
|
||||
"uncategorizedExports": "未分类导出项",
|
||||
"cases": "合集"
|
||||
"cases": "案例"
|
||||
},
|
||||
"caseDialog": {
|
||||
"nameLabel": "合集名称",
|
||||
"title": "加入合集",
|
||||
"description": "选择现有合集或创建新合集。",
|
||||
"selectLabel": "合集",
|
||||
"newCaseOption": "创建新合集",
|
||||
"descriptionLabel": "描述"
|
||||
"nameLabel": "片段名称"
|
||||
}
|
||||
}
|
||||
|
||||
@ -216,36 +216,7 @@
|
||||
}
|
||||
},
|
||||
"mqtt": {
|
||||
"label": "MQTT",
|
||||
"description": "MQTT 图像发布设置。",
|
||||
"enabled": {
|
||||
"label": "发送图像",
|
||||
"description": "为此摄像头启用向 MQTT 主题发布目标图像快照。"
|
||||
},
|
||||
"timestamp": {
|
||||
"label": "添加时间戳",
|
||||
"description": "在发布到 MQTT 的图像上叠加时间戳。"
|
||||
},
|
||||
"bounding_box": {
|
||||
"label": "添加边界框",
|
||||
"description": "在通过 MQTT 发布的图像上绘制边界框。"
|
||||
},
|
||||
"crop": {
|
||||
"label": "裁剪图像",
|
||||
"description": "将发布到 MQTT 的图像裁剪到检测到的目标边界框。"
|
||||
},
|
||||
"height": {
|
||||
"label": "图像高度",
|
||||
"description": "通过 MQTT 发布的图像的调整高度(像素)。"
|
||||
},
|
||||
"required_zones": {
|
||||
"label": "必需区域",
|
||||
"description": "目标必须进入才能发布 MQTT 图像的区域。"
|
||||
},
|
||||
"quality": {
|
||||
"label": "JPEG 质量",
|
||||
"description": "发布到 MQTT 的图像的 JPEG 质量(0-100)。"
|
||||
}
|
||||
"label": "MQTT"
|
||||
},
|
||||
"notifications": {
|
||||
"label": "通知",
|
||||
@ -281,59 +252,57 @@
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"label": "画面变动检测",
|
||||
"label": "动作检测",
|
||||
"enabled": {
|
||||
"label": "开启画面变动检测",
|
||||
"description": "开启或关闭此摄像头的画面变动检测。"
|
||||
"label": "开启动作检测"
|
||||
},
|
||||
"threshold": {
|
||||
"label": "画面变动阈值",
|
||||
"description": "画面变动检测器使用的像素差异阈值;数值越高灵敏度越低(范围 1-255)。"
|
||||
"label": "动作阈值",
|
||||
"description": "动作检测器使用的像素差异阈值;数值越高灵敏度越低(范围 1-255)。"
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"label": "闪电阈值",
|
||||
"description": "用于检测和忽略短暂闪电闪烁的阈值(数值越低越敏感,范围 0.3 到 1.0)。这不会完全阻止画面变动检测;只是当超过阈值时检测器会停止分析额外的帧。在此类事件期间仍会创建基于画面变动的录像。"
|
||||
"description": "用于检测和忽略短暂闪电闪烁的阈值(数值越低越敏感,范围 0.3 到 1.0)。这不会完全阻止动作检测;只是当超过阈值时检测器会停止分析额外的帧。在此类事件期间仍会创建基于动作的录像。"
|
||||
},
|
||||
"skip_motion_threshold": {
|
||||
"label": "跳过画面变动阈值",
|
||||
"description": "如果单帧中图像变化超过此比例,检测器将返回无画面变动框并立即重新校准。这可以节省 CPU 并减少闪电、风暴等情况下的误报,但可能会错过真实事件,如 PTZ 摄像头自动追踪目标。权衡的是丢弃几兆字节的录像与查看几个短片之间的取舍。范围 0.0 到 1.0。"
|
||||
"label": "跳过动作阈值",
|
||||
"description": "如果单帧中图像变化超过此比例,检测器将返回无动作框并立即重新校准。这可以节省 CPU 并减少闪电、风暴等情况下的误报,但可能会错过真实事件,如 PTZ 摄像头自动追踪目标。权衡的是丢弃几兆字节的录像与查看几个短片之间的取舍。范围 0.0 到 1.0。"
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "改善对比度",
|
||||
"description": "在画面变动分析之前对帧应用对比度改善以帮助检测。"
|
||||
"description": "在动作分析之前对帧应用对比度改善以帮助检测。"
|
||||
},
|
||||
"contour_area": {
|
||||
"label": "轮廓区域",
|
||||
"description": "画面变动轮廓被计入所需的最小轮廓区域(像素)。"
|
||||
"description": "动作轮廓被计入所需的最小轮廓区域(像素)。"
|
||||
},
|
||||
"delta_alpha": {
|
||||
"label": "Delta alpha",
|
||||
"description": "用于画面变动计算的帧差异中使用的 alpha 混合因子。"
|
||||
"description": "用于动作计算的帧差异中使用的 alpha 混合因子。"
|
||||
},
|
||||
"frame_alpha": {
|
||||
"label": "画面 alpha 通道",
|
||||
"description": "画面变动预处理时混合画面所使用的 alpha 值。"
|
||||
"label": "帧 alpha",
|
||||
"description": "动作预处理时混合帧所使用的 alpha 值。"
|
||||
},
|
||||
"frame_height": {
|
||||
"label": "画面高度",
|
||||
"description": "计算画面变动时缩放画面的高度(像素)。"
|
||||
"label": "帧高度",
|
||||
"description": "计算动作时缩放帧的高度(像素)。"
|
||||
},
|
||||
"mask": {
|
||||
"label": "遮罩坐标",
|
||||
"description": "定义用于包含/排除区域的画面变动遮罩多边形的有序 x,y 坐标。"
|
||||
"description": "定义用于包含/排除区域的动作遮罩多边形的有序 x,y 坐标。"
|
||||
},
|
||||
"mqtt_off_delay": {
|
||||
"label": "MQTT 关闭延迟",
|
||||
"description": "在发布 MQTT 'off' 状态之前,最后一次画面变动后等待的秒数。"
|
||||
"description": "在发布 MQTT 'off' 状态之前,最后一次动作后等待的秒数。"
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "原始画面变动状态",
|
||||
"description": "指示原始静态配置中是否启用了画面变动检测。"
|
||||
"label": "原始动作状态",
|
||||
"description": "指示原始静态配置中是否启用了动作检测。"
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "原始遮罩"
|
||||
},
|
||||
"description": "此摄像头的默认画面变动检测设置。"
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
"label": "目标",
|
||||
@ -435,8 +404,7 @@
|
||||
"record": {
|
||||
"label": "录像",
|
||||
"enabled": {
|
||||
"label": "开启录像",
|
||||
"description": "开启或关闭此摄像头的录像。"
|
||||
"label": "开启录像"
|
||||
},
|
||||
"expire_interval": {
|
||||
"label": "录像清理间隔",
|
||||
@ -525,8 +493,7 @@
|
||||
"enabled_in_config": {
|
||||
"label": "原始录像状态",
|
||||
"description": "指示原始静态配置中是否启用了录像。"
|
||||
},
|
||||
"description": "此摄像头的录像和保留设置。"
|
||||
}
|
||||
},
|
||||
"review": {
|
||||
"label": "核查",
|
||||
@ -534,8 +501,7 @@
|
||||
"label": "警报配置",
|
||||
"description": "哪些追踪目标生成警报以及如何保留警报的设置。",
|
||||
"enabled": {
|
||||
"label": "开启警报",
|
||||
"description": "开启或关闭此摄像头的警报生成。"
|
||||
"label": "开启警报"
|
||||
},
|
||||
"labels": {
|
||||
"label": "警报标签",
|
||||
@ -558,8 +524,7 @@
|
||||
"label": "检测配置",
|
||||
"description": "创建检测事件(非警报)以及保留多长时间的设置。",
|
||||
"enabled": {
|
||||
"label": "开启检测",
|
||||
"description": "开启或关闭此摄像头的检测事件。"
|
||||
"label": "开启检测"
|
||||
},
|
||||
"labels": {
|
||||
"label": "检测标签",
|
||||
@ -617,14 +582,12 @@
|
||||
"label": "活动上下文提示",
|
||||
"description": "描述什么是和什么不是可疑活动的自定义提示,为 GenAI 摘要提供上下文。"
|
||||
}
|
||||
},
|
||||
"description": "控制此摄像头的警报、检测和 GenAI 核查摘要的设置,用于 UI 和存储。"
|
||||
}
|
||||
},
|
||||
"snapshots": {
|
||||
"label": "快照",
|
||||
"enabled": {
|
||||
"label": "开启快照",
|
||||
"description": "开启或关闭此摄像头的快照保存。"
|
||||
"label": "开启快照"
|
||||
},
|
||||
"clean_copy": {
|
||||
"label": "保存干净副本",
|
||||
@ -669,8 +632,7 @@
|
||||
"quality": {
|
||||
"label": "JPEG 质量",
|
||||
"description": "保存快照的 JPEG 编码质量(0-100)。"
|
||||
},
|
||||
"description": "此摄像头保存的追踪目标 JPEG 快照设置。"
|
||||
}
|
||||
},
|
||||
"timestamp_style": {
|
||||
"label": "时间戳样式",
|
||||
@ -705,8 +667,7 @@
|
||||
"effect": {
|
||||
"label": "时间戳效果",
|
||||
"description": "时间戳文本的视觉效果(none、solid、shadow)。"
|
||||
},
|
||||
"description": "应用于录像和快照的实时监控流中时间戳的样式选项。"
|
||||
}
|
||||
},
|
||||
"semantic_search": {
|
||||
"label": "语义搜索",
|
||||
@ -737,8 +698,7 @@
|
||||
"label": "触发器操作",
|
||||
"description": "触发器匹配时要执行的操作列表(通知、sub_label、属性)。"
|
||||
}
|
||||
},
|
||||
"description": "语义搜索设置,用于构建和查询目标嵌入以查找相似项目。"
|
||||
}
|
||||
},
|
||||
"lpr": {
|
||||
"label": "车牌识别",
|
||||
@ -832,105 +792,6 @@
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"label": "摄像头 UI",
|
||||
"description": "此摄像头在 UI 中的显示顺序和可见性。顺序影响默认仪表板。如需更精细的控制,请使用摄像头组。",
|
||||
"order": {
|
||||
"label": "UI 顺序",
|
||||
"description": "用于在 UI 中排序摄像头的数值顺序(默认仪表板和列表);数值越大出现越晚。"
|
||||
},
|
||||
"dashboard": {
|
||||
"label": "在 UI 中显示",
|
||||
"description": "切换此摄像头在 Frigate UI 的所有位置是否可见。禁用此项将需要手动编辑配置才能在 UI 中再次查看此摄像头。"
|
||||
}
|
||||
},
|
||||
"best_image_timeout": {
|
||||
"label": "最佳图像超时",
|
||||
"description": "等待具有最高置信度分数的图像的时间。"
|
||||
},
|
||||
"type": {
|
||||
"label": "摄像头类型",
|
||||
"description": "摄像头类型"
|
||||
},
|
||||
"webui_url": {
|
||||
"label": "摄像头 URL",
|
||||
"description": "从系统页面直接访问摄像头的 URL"
|
||||
},
|
||||
"zones": {
|
||||
"label": "区域",
|
||||
"description": "区域允许您定义帧的特定区域,以便确定目标是否在特定区域内。",
|
||||
"friendly_name": {
|
||||
"label": "区域名称",
|
||||
"description": "区域的友好名称,显示在 Frigate UI 中。如果未设置,将使用区域名称的格式化版本。"
|
||||
},
|
||||
"enabled": {
|
||||
"label": "开启",
|
||||
"description": "开启或关闭此区域。禁用的区域在运行时将被忽略。"
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "保持区域原始状态的跟踪。"
|
||||
},
|
||||
"filters": {
|
||||
"label": "区域过滤器",
|
||||
"description": "应用于此区域内目标的过滤器。用于减少误报或限制哪些目标被认为存在于区域内。",
|
||||
"min_area": {
|
||||
"label": "最小目标区域",
|
||||
"description": "此目标类型所需的最小边界框区域(像素或百分比)。可以是像素(整数)或百分比(0.000001 到 0.99 之间的浮点数)。"
|
||||
},
|
||||
"max_area": {
|
||||
"label": "最大目标区域",
|
||||
"description": "此目标类型允许的最大边界框区域(像素或百分比)。可以是像素(整数)或百分比(0.000001 到 0.99 之间的浮点数)。"
|
||||
},
|
||||
"min_ratio": {
|
||||
"label": "最小纵横比",
|
||||
"description": "边界框所需的最小宽高比。"
|
||||
},
|
||||
"max_ratio": {
|
||||
"label": "最大纵横比",
|
||||
"description": "边界框允许的最大宽高比。"
|
||||
},
|
||||
"threshold": {
|
||||
"label": "置信度阈值",
|
||||
"description": "目标被视为真正阳性所需的平均检测置信度阈值。"
|
||||
},
|
||||
"min_score": {
|
||||
"label": "最小置信度",
|
||||
"description": "目标被计入所需的最小单帧检测置信度。"
|
||||
},
|
||||
"mask": {
|
||||
"label": "过滤器遮罩",
|
||||
"description": "定义此过滤器在帧内应用位置的多边形坐标。"
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "原始遮罩"
|
||||
}
|
||||
},
|
||||
"coordinates": {
|
||||
"label": "坐标",
|
||||
"description": "定义区域区域的多边形坐标。可以是逗号分隔的字符串或坐标字符串列表。坐标应该是相对的(0-1)或绝对的(传统)。"
|
||||
},
|
||||
"distances": {
|
||||
"label": "真实世界距离",
|
||||
"description": "区域四边形每边的可选真实世界距离,用于速度或距离计算。如果设置,必须恰好有 4 个值。"
|
||||
},
|
||||
"inertia": {
|
||||
"label": "惯性帧数",
|
||||
"description": "目标必须在区域内被连续检测多少帧才能被认为存在。有助于过滤掉短暂检测。"
|
||||
},
|
||||
"loitering_time": {
|
||||
"label": "徘徊秒数",
|
||||
"description": "目标必须在区域内停留多少秒才能被视为徘徊。设置为 0 可禁用徘徊检测。"
|
||||
},
|
||||
"speed_threshold": {
|
||||
"label": "最小速度",
|
||||
"description": "目标被认为存在于区域所需的最小速度(如果设置了距离,则为真实世界单位)。用于基于速度的区域触发器。"
|
||||
},
|
||||
"objects": {
|
||||
"label": "触发目标",
|
||||
"description": "可以触发此区域的目标类型列表(来自标签映射)。可以是字符串或字符串列表。如果为空,则考虑所有目标。"
|
||||
}
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "原始摄像头状态",
|
||||
"description": "保持摄像头的原始状态跟踪。"
|
||||
"label": "摄像头 UI"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1447,55 +1447,55 @@
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"label": "画面变动检测",
|
||||
"label": "动作检测",
|
||||
"description": "应用于摄像头的默认动作检测设置,除非按摄像头覆盖。",
|
||||
"enabled": {
|
||||
"label": "开启画面变动检测",
|
||||
"label": "开启动作检测",
|
||||
"description": "为所有摄像头启用或禁用动作检测;可按摄像头覆盖。"
|
||||
},
|
||||
"threshold": {
|
||||
"label": "画面变动阈值",
|
||||
"description": "画面变动检测器使用的像素差异阈值;数值越高灵敏度越低(范围 1-255)。"
|
||||
"label": "动作阈值",
|
||||
"description": "动作检测器使用的像素差异阈值;数值越高灵敏度越低(范围 1-255)。"
|
||||
},
|
||||
"lightning_threshold": {
|
||||
"label": "闪电阈值",
|
||||
"description": "用于检测和忽略短暂闪电闪烁的阈值(数值越低越敏感,范围 0.3 到 1.0)。这不会完全阻止画面变动检测;只是当超过阈值时检测器会停止分析额外的帧。在此类事件期间仍会创建基于画面变动的录像。"
|
||||
"description": "用于检测和忽略短暂闪电闪烁的阈值(数值越低越敏感,范围 0.3 到 1.0)。这不会完全阻止动作检测;只是当超过阈值时检测器会停止分析额外的帧。在此类事件期间仍会创建基于动作的录像。"
|
||||
},
|
||||
"skip_motion_threshold": {
|
||||
"label": "跳过画面变动阈值",
|
||||
"description": "如果单帧中图像变化超过此比例,检测器将返回无画面变动框并立即重新校准。这可以节省 CPU 并减少闪电、风暴等情况下的误报,但可能会错过真实事件,如 PTZ 摄像头自动追踪目标。权衡的是丢弃几兆字节的录像与查看几个短片之间的取舍。范围 0.0 到 1.0。"
|
||||
"label": "跳过动作阈值",
|
||||
"description": "如果单帧中图像变化超过此比例,检测器将返回无动作框并立即重新校准。这可以节省 CPU 并减少闪电、风暴等情况下的误报,但可能会错过真实事件,如 PTZ 摄像头自动追踪目标。权衡的是丢弃几兆字节的录像与查看几个短片之间的取舍。范围 0.0 到 1.0。"
|
||||
},
|
||||
"improve_contrast": {
|
||||
"label": "改善对比度",
|
||||
"description": "在画面变动分析之前对帧应用对比度改善以帮助检测。"
|
||||
"description": "在动作分析之前对帧应用对比度改善以帮助检测。"
|
||||
},
|
||||
"contour_area": {
|
||||
"label": "轮廓区域",
|
||||
"description": "画面变动轮廓被计入所需的最小轮廓区域(像素)。"
|
||||
"description": "动作轮廓被计入所需的最小轮廓区域(像素)。"
|
||||
},
|
||||
"delta_alpha": {
|
||||
"label": "Delta alpha",
|
||||
"description": "用于画面变动计算的帧差异中使用的 alpha 混合因子。"
|
||||
"description": "用于动作计算的帧差异中使用的 alpha 混合因子。"
|
||||
},
|
||||
"frame_alpha": {
|
||||
"label": "画面 alpha 通道",
|
||||
"description": "画面变动预处理时混合画面所使用的 alpha 值。"
|
||||
"label": "帧 alpha",
|
||||
"description": "动作预处理时混合帧所使用的 alpha 值。"
|
||||
},
|
||||
"frame_height": {
|
||||
"label": "画面高度",
|
||||
"description": "计算画面变动时缩放画面的高度(像素)。"
|
||||
"label": "帧高度",
|
||||
"description": "计算动作时缩放帧的高度(像素)。"
|
||||
},
|
||||
"mask": {
|
||||
"label": "遮罩坐标",
|
||||
"description": "定义用于包含/排除区域的画面变动遮罩多边形的有序 x,y 坐标。"
|
||||
"description": "定义用于包含/排除区域的动作遮罩多边形的有序 x,y 坐标。"
|
||||
},
|
||||
"mqtt_off_delay": {
|
||||
"label": "MQTT 关闭延迟",
|
||||
"description": "在发布 MQTT 'off' 状态之前,最后一次画面变动后等待的秒数。"
|
||||
"description": "在发布 MQTT 'off' 状态之前,最后一次动作后等待的秒数。"
|
||||
},
|
||||
"enabled_in_config": {
|
||||
"label": "原始画面变动状态",
|
||||
"description": "指示原始静态配置中是否启用了画面变动检测。"
|
||||
"label": "原始动作状态",
|
||||
"description": "指示原始静态配置中是否启用了动作检测。"
|
||||
},
|
||||
"raw_mask": {
|
||||
"label": "原始遮罩"
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
{
|
||||
"audio": {
|
||||
"global": {
|
||||
"detection": "全局检测",
|
||||
"detection": "全局检测器",
|
||||
"sensitivity": "全局灵敏度"
|
||||
},
|
||||
"cameras": {
|
||||
"detection": "检测",
|
||||
"detection": "检测器",
|
||||
"sensitivity": "灵敏度"
|
||||
}
|
||||
},
|
||||
"timestamp_style": {
|
||||
"global": {
|
||||
"appearance": "全局外观"
|
||||
"appearance": "全局样式"
|
||||
},
|
||||
"cameras": {
|
||||
"appearance": "外观"
|
||||
"appearance": "样式"
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
@ -37,37 +37,7 @@
|
||||
},
|
||||
"record": {
|
||||
"global": {
|
||||
"retention": "全局保留",
|
||||
"events": "全局事件"
|
||||
},
|
||||
"cameras": {
|
||||
"retention": "保留",
|
||||
"events": "事件"
|
||||
}
|
||||
},
|
||||
"detect": {
|
||||
"global": {
|
||||
"resolution": "全局分辨率",
|
||||
"tracking": "全局追踪"
|
||||
},
|
||||
"cameras": {
|
||||
"resolution": "分辨率",
|
||||
"tracking": "追踪"
|
||||
}
|
||||
},
|
||||
"objects": {
|
||||
"global": {
|
||||
"tracking": "全局追踪",
|
||||
"filtering": "全局筛选"
|
||||
},
|
||||
"cameras": {
|
||||
"tracking": "追踪",
|
||||
"filtering": "筛选"
|
||||
}
|
||||
},
|
||||
"ffmpeg": {
|
||||
"cameras": {
|
||||
"cameraFfmpeg": "摄像头特定的 FFmpeg 参数"
|
||||
"retention": "全局分辨率"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
import { baseUrl } from "./baseUrl";
|
||||
import { ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import { WsSendContext } from "./wsContext";
|
||||
import type { Update } from "./wsContext";
|
||||
import { processWsMessage, resetWsStore } from "./ws";
|
||||
|
||||
export function WsProvider({ children }: { children: ReactNode }) {
|
||||
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttempt = useRef(0);
|
||||
const unmounted = useRef(false);
|
||||
|
||||
const sendJsonMessage = useCallback((msg: unknown) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
|
||||
function connect() {
|
||||
if (unmounted.current) return;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt.current = 0;
|
||||
ws.send(
|
||||
JSON.stringify({ topic: "onConnect", message: "", retain: false }),
|
||||
);
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
processWsMessage(event.data as string);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted.current) return;
|
||||
const delay = Math.min(1000 * 2 ** reconnectAttempt.current, 30000);
|
||||
reconnectAttempt.current++;
|
||||
reconnectTimer.current = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
unmounted.current = true;
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
}
|
||||
wsRef.current?.close();
|
||||
resetWsStore();
|
||||
};
|
||||
}, [wsUrl]);
|
||||
|
||||
const send = useCallback(
|
||||
(message: Update) => {
|
||||
sendJsonMessage({
|
||||
topic: message.topic,
|
||||
payload: message.payload,
|
||||
retain: message.retain,
|
||||
});
|
||||
},
|
||||
[sendJsonMessage],
|
||||
);
|
||||
|
||||
return (
|
||||
<WsSendContext.Provider value={send}>{children}</WsSendContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { baseUrl } from "./baseUrl";
|
||||
import { SWRConfig } from "swr";
|
||||
import { WsProvider } from "./WsProvider";
|
||||
import { WsProvider } from "./ws";
|
||||
import axios from "axios";
|
||||
import { ReactNode } from "react";
|
||||
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { baseUrl } from "./baseUrl";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||
import {
|
||||
EmbeddingsReindexProgressType,
|
||||
FrigateCameraState,
|
||||
@ -19,11 +14,8 @@ import {
|
||||
Job,
|
||||
} from "@/types/ws";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { isEqual } from "lodash";
|
||||
import { WsSendContext } from "./wsContext";
|
||||
import type { Update, WsSend } from "./wsContext";
|
||||
|
||||
export type { Update };
|
||||
import { createContainer } from "react-tracked";
|
||||
import useDeepMemo from "@/hooks/use-deep-memo";
|
||||
|
||||
export type WsFeedMessage = {
|
||||
topic: string;
|
||||
@ -32,204 +24,170 @@ export type WsFeedMessage = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type Update = {
|
||||
topic: string;
|
||||
payload: unknown;
|
||||
retain: boolean;
|
||||
};
|
||||
|
||||
type WsState = {
|
||||
[topic: string]: unknown;
|
||||
};
|
||||
|
||||
// External store for WebSocket state using useSyncExternalStore
|
||||
type Listener = () => void;
|
||||
type useValueReturn = [WsState, (update: Update) => void];
|
||||
|
||||
const wsState: WsState = {};
|
||||
const wsTopicListeners = new Map<string, Set<Listener>>();
|
||||
|
||||
// Reset all module-level state. Called on WsProvider unmount to prevent
|
||||
// stale data from leaking across mount/unmount cycles (e.g. HMR, logout)
|
||||
export function resetWsStore() {
|
||||
for (const key of Object.keys(wsState)) {
|
||||
delete wsState[key];
|
||||
}
|
||||
wsTopicListeners.clear();
|
||||
lastCameraActivityPayload = null;
|
||||
wsMessageSubscribers.clear();
|
||||
wsMessageIdCounter = 0;
|
||||
}
|
||||
|
||||
// Parse and apply a raw WS message synchronously.
|
||||
// Called directly from WsProvider's onmessage handler.
|
||||
export function processWsMessage(raw: string) {
|
||||
const data: Update = JSON.parse(raw);
|
||||
if (!data) return;
|
||||
|
||||
const { topic, payload } = data;
|
||||
|
||||
if (topic === "camera_activity") {
|
||||
applyCameraActivity(payload as string);
|
||||
} else {
|
||||
applyTopicUpdate(topic, payload);
|
||||
}
|
||||
|
||||
if (wsMessageSubscribers.size > 0) {
|
||||
wsMessageSubscribers.forEach((cb) =>
|
||||
cb({
|
||||
topic,
|
||||
payload,
|
||||
timestamp: Date.now(),
|
||||
id: String(wsMessageIdCounter++),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTopicUpdate(topic: string, newVal: unknown) {
|
||||
const oldVal = wsState[topic];
|
||||
// Fast path: === for primitives ("ON"/"OFF", numbers).
|
||||
// Fall back to isEqual for objects/arrays.
|
||||
const unchanged =
|
||||
oldVal === newVal ||
|
||||
(typeof newVal === "object" && newVal !== null && isEqual(oldVal, newVal));
|
||||
if (unchanged) return;
|
||||
|
||||
wsState[topic] = newVal;
|
||||
// Snapshot the Set — a listener may trigger unmount that modifies it.
|
||||
const listeners = wsTopicListeners.get(topic);
|
||||
if (listeners) {
|
||||
for (const l of Array.from(listeners)) l();
|
||||
}
|
||||
}
|
||||
|
||||
// Subscriptions
|
||||
|
||||
export function subscribeWsTopic(
|
||||
topic: string,
|
||||
listener: Listener,
|
||||
): () => void {
|
||||
let set = wsTopicListeners.get(topic);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
wsTopicListeners.set(topic, set);
|
||||
}
|
||||
set.add(listener);
|
||||
return () => {
|
||||
set!.delete(listener);
|
||||
if (set!.size === 0) wsTopicListeners.delete(topic);
|
||||
};
|
||||
}
|
||||
|
||||
export function getWsTopicValue(topic: string): unknown {
|
||||
return wsState[topic];
|
||||
}
|
||||
|
||||
// Feed message subscribers
|
||||
const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>();
|
||||
let wsMessageIdCounter = 0;
|
||||
|
||||
// Camera activity expansion
|
||||
//
|
||||
// Cache the last raw camera_activity JSON string so we can skip JSON.parse
|
||||
// and the entire expansion when nothing has changed. This avoids creating
|
||||
// fresh objects (which defeat Object.is and force expensive isEqual deep
|
||||
// traversals) on every flush — critical with many cameras.
|
||||
let lastCameraActivityPayload: string | null = null;
|
||||
function useValue(): useValueReturn {
|
||||
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
|
||||
|
||||
function applyCameraActivity(payload: string) {
|
||||
// Fast path: if the raw JSON string is identical, nothing changed.
|
||||
if (payload === lastCameraActivityPayload) return;
|
||||
lastCameraActivityPayload = payload;
|
||||
// main state
|
||||
|
||||
let activity: { [key: string]: Partial<FrigateCameraState> };
|
||||
const [wsState, setWsState] = useState<WsState>({});
|
||||
|
||||
try {
|
||||
activity = JSON.parse(payload);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
const activityValue: string = wsState["camera_activity"] as string;
|
||||
|
||||
if (Object.keys(activity).length === 0) return;
|
||||
if (!activityValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, state] of Object.entries(activity)) {
|
||||
applyTopicUpdate(`camera_activity/${name}`, state);
|
||||
let cameraActivity: { [key: string]: Partial<FrigateCameraState> };
|
||||
|
||||
const cameraConfig = state?.config;
|
||||
if (!cameraConfig) continue;
|
||||
try {
|
||||
cameraActivity = JSON.parse(activityValue);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
record,
|
||||
detect,
|
||||
enabled,
|
||||
snapshots,
|
||||
audio,
|
||||
audio_transcription,
|
||||
notifications,
|
||||
notifications_suspended,
|
||||
autotracking,
|
||||
alerts,
|
||||
detections,
|
||||
object_descriptions,
|
||||
review_descriptions,
|
||||
} = cameraConfig;
|
||||
if (Object.keys(cameraActivity).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyTopicUpdate(`${name}/recordings/state`, record ? "ON" : "OFF");
|
||||
applyTopicUpdate(`${name}/enabled/state`, enabled ? "ON" : "OFF");
|
||||
applyTopicUpdate(`${name}/detect/state`, detect ? "ON" : "OFF");
|
||||
applyTopicUpdate(`${name}/snapshots/state`, snapshots ? "ON" : "OFF");
|
||||
applyTopicUpdate(`${name}/audio/state`, audio ? "ON" : "OFF");
|
||||
applyTopicUpdate(
|
||||
`${name}/audio_transcription/state`,
|
||||
audio_transcription ? "ON" : "OFF",
|
||||
);
|
||||
applyTopicUpdate(
|
||||
`${name}/notifications/state`,
|
||||
notifications ? "ON" : "OFF",
|
||||
);
|
||||
applyTopicUpdate(
|
||||
`${name}/notifications/suspended`,
|
||||
notifications_suspended || 0,
|
||||
);
|
||||
applyTopicUpdate(
|
||||
`${name}/ptz_autotracker/state`,
|
||||
autotracking ? "ON" : "OFF",
|
||||
);
|
||||
applyTopicUpdate(`${name}/review_alerts/state`, alerts ? "ON" : "OFF");
|
||||
applyTopicUpdate(
|
||||
`${name}/review_detections/state`,
|
||||
detections ? "ON" : "OFF",
|
||||
);
|
||||
applyTopicUpdate(
|
||||
`${name}/object_descriptions/state`,
|
||||
object_descriptions ? "ON" : "OFF",
|
||||
);
|
||||
applyTopicUpdate(
|
||||
`${name}/review_descriptions/state`,
|
||||
review_descriptions ? "ON" : "OFF",
|
||||
);
|
||||
}
|
||||
}
|
||||
const cameraStates: WsState = {};
|
||||
|
||||
// Hooks
|
||||
export function useWsUpdate(): WsSend {
|
||||
const send = useContext(WsSendContext);
|
||||
if (!send) {
|
||||
throw new Error("useWsUpdate must be used within WsProvider");
|
||||
}
|
||||
return send;
|
||||
}
|
||||
Object.entries(cameraActivity).forEach(([name, state]) => {
|
||||
const cameraConfig = state?.config;
|
||||
|
||||
// Subscribe to a single WS topic with proper bail-out.
|
||||
// Only re-renders when the topic's value changes (Object.is comparison).
|
||||
// Uses useSyncExternalStore — zero useEffect, so no PassiveMask flags
|
||||
// propagate through the fiber tree.
|
||||
export function useWs(watchTopic: string, publishTopic: string) {
|
||||
const payload = useSyncExternalStore(
|
||||
useCallback(
|
||||
(listener: Listener) => subscribeWsTopic(watchTopic, listener),
|
||||
[watchTopic],
|
||||
),
|
||||
useCallback(() => wsState[watchTopic], [watchTopic]),
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
record,
|
||||
detect,
|
||||
enabled,
|
||||
snapshots,
|
||||
audio,
|
||||
audio_transcription,
|
||||
notifications,
|
||||
notifications_suspended,
|
||||
autotracking,
|
||||
alerts,
|
||||
detections,
|
||||
object_descriptions,
|
||||
review_descriptions,
|
||||
} = cameraConfig;
|
||||
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
||||
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
|
||||
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
||||
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
|
||||
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
|
||||
cameraStates[`${name}/audio_transcription/state`] = audio_transcription
|
||||
? "ON"
|
||||
: "OFF";
|
||||
cameraStates[`${name}/notifications/state`] = notifications
|
||||
? "ON"
|
||||
: "OFF";
|
||||
cameraStates[`${name}/notifications/suspended`] =
|
||||
notifications_suspended || 0;
|
||||
cameraStates[`${name}/ptz_autotracker/state`] = autotracking
|
||||
? "ON"
|
||||
: "OFF";
|
||||
cameraStates[`${name}/review_alerts/state`] = alerts ? "ON" : "OFF";
|
||||
cameraStates[`${name}/review_detections/state`] = detections
|
||||
? "ON"
|
||||
: "OFF";
|
||||
cameraStates[`${name}/object_descriptions/state`] = object_descriptions
|
||||
? "ON"
|
||||
: "OFF";
|
||||
cameraStates[`${name}/review_descriptions/state`] = review_descriptions
|
||||
? "ON"
|
||||
: "OFF";
|
||||
});
|
||||
|
||||
setWsState((prevState) => ({
|
||||
...prevState,
|
||||
...cameraStates,
|
||||
}));
|
||||
|
||||
// we only want this to run initially when the config is loaded
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wsState["camera_activity"]]);
|
||||
|
||||
// ws handler
|
||||
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
|
||||
onMessage: (event) => {
|
||||
const data: Update = JSON.parse(event.data);
|
||||
|
||||
if (data) {
|
||||
setWsState((prevState) => ({
|
||||
...prevState,
|
||||
[data.topic]: data.payload,
|
||||
}));
|
||||
|
||||
// Notify feed subscribers
|
||||
if (wsMessageSubscribers.size > 0) {
|
||||
const feedMsg: WsFeedMessage = {
|
||||
topic: data.topic,
|
||||
payload: data.payload,
|
||||
timestamp: Date.now(),
|
||||
id: String(wsMessageIdCounter++),
|
||||
};
|
||||
wsMessageSubscribers.forEach((cb) => cb(feedMsg));
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpen: () => {
|
||||
sendJsonMessage({
|
||||
topic: "onConnect",
|
||||
message: "",
|
||||
retain: false,
|
||||
});
|
||||
},
|
||||
onClose: () => {},
|
||||
shouldReconnect: () => true,
|
||||
retryOnError: true,
|
||||
});
|
||||
|
||||
const setState = useCallback(
|
||||
(message: Update) => {
|
||||
if (readyState === ReadyState.OPEN) {
|
||||
sendJsonMessage({
|
||||
topic: message.topic,
|
||||
payload: message.payload,
|
||||
retain: message.retain,
|
||||
});
|
||||
}
|
||||
},
|
||||
[readyState, sendJsonMessage],
|
||||
);
|
||||
|
||||
return [wsState, setState];
|
||||
}
|
||||
|
||||
export const {
|
||||
Provider: WsProvider,
|
||||
useTrackedState: useWsState,
|
||||
useUpdate: useWsUpdate,
|
||||
} = createContainer(useValue, { defaultState: {}, concurrentMode: true });
|
||||
|
||||
export function useWs(watchTopic: string, publishTopic: string) {
|
||||
const state = useWsState();
|
||||
const sendJsonMessage = useWsUpdate();
|
||||
|
||||
const value = { payload: payload ?? null };
|
||||
const value = { payload: state[watchTopic] || null };
|
||||
|
||||
const send = useCallback(
|
||||
(payload: unknown, retain = false) => {
|
||||
@ -245,8 +203,6 @@ export function useWs(watchTopic: string, publishTopic: string) {
|
||||
return { value, send };
|
||||
}
|
||||
|
||||
// Convenience hooks
|
||||
|
||||
export function useEnabledState(camera: string): {
|
||||
payload: ToggleableSetting;
|
||||
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
||||
@ -457,42 +413,28 @@ export function useFrigateEvents(): { payload: FrigateEvent } {
|
||||
const {
|
||||
value: { payload },
|
||||
} = useWs("events", "");
|
||||
const parsed = useMemo(
|
||||
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||
[payload],
|
||||
);
|
||||
return { payload: parsed };
|
||||
return { payload: JSON.parse(payload as string) };
|
||||
}
|
||||
|
||||
export function useAudioDetections(): { payload: FrigateAudioDetections } {
|
||||
const {
|
||||
value: { payload },
|
||||
} = useWs("audio_detections", "");
|
||||
const parsed = useMemo(
|
||||
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||
[payload],
|
||||
);
|
||||
return { payload: parsed };
|
||||
return { payload: JSON.parse(payload as string) };
|
||||
}
|
||||
|
||||
export function useFrigateReviews(): FrigateReview {
|
||||
const {
|
||||
value: { payload },
|
||||
} = useWs("reviews", "");
|
||||
return useMemo(
|
||||
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||
[payload],
|
||||
);
|
||||
return useDeepMemo(JSON.parse(payload as string));
|
||||
}
|
||||
|
||||
export function useFrigateStats(): FrigateStats {
|
||||
const {
|
||||
value: { payload },
|
||||
} = useWs("stats", "");
|
||||
return useMemo(
|
||||
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||
[payload],
|
||||
);
|
||||
return useDeepMemo(JSON.parse(payload as string));
|
||||
}
|
||||
|
||||
export function useInitialCameraState(
|
||||
@ -504,31 +446,32 @@ export function useInitialCameraState(
|
||||
const {
|
||||
value: { payload },
|
||||
send: sendCommand,
|
||||
} = useWs(`camera_activity/${camera}`, "onConnect");
|
||||
} = useWs("camera_activity", "onConnect");
|
||||
|
||||
// camera_activity sub-topic payload is already parsed by expandCameraActivity
|
||||
const data = payload as FrigateCameraState | undefined;
|
||||
const data = useDeepMemo(JSON.parse(payload as string));
|
||||
|
||||
// onConnect is sent once in WsProvider.onopen — no need to re-request on
|
||||
// every component mount. Components read cached wsState immediately via
|
||||
// useSyncExternalStore. Only re-request when the user tabs back in.
|
||||
useEffect(() => {
|
||||
if (!revalidateOnFocus) return;
|
||||
|
||||
const listener = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
sendCommand("onConnect");
|
||||
}
|
||||
};
|
||||
addEventListener("visibilitychange", listener);
|
||||
let listener = undefined;
|
||||
if (revalidateOnFocus) {
|
||||
sendCommand("onConnect");
|
||||
listener = () => {
|
||||
if (document.visibilityState == "visible") {
|
||||
sendCommand("onConnect");
|
||||
}
|
||||
};
|
||||
addEventListener("visibilitychange", listener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
removeEventListener("visibilitychange", listener);
|
||||
if (listener) {
|
||||
removeEventListener("visibilitychange", listener);
|
||||
}
|
||||
};
|
||||
// only refresh when onRefresh value changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [revalidateOnFocus]);
|
||||
|
||||
return { payload: data as FrigateCameraState };
|
||||
return { payload: data ? data[camera] : undefined };
|
||||
}
|
||||
|
||||
export function useModelState(
|
||||
@ -540,10 +483,7 @@ export function useModelState(
|
||||
send: sendCommand,
|
||||
} = useWs("model_state", "modelState");
|
||||
|
||||
const data = useMemo(
|
||||
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||
[payload],
|
||||
);
|
||||
const data = useDeepMemo(JSON.parse(payload as string));
|
||||
|
||||
useEffect(() => {
|
||||
let listener = undefined;
|
||||
@ -579,10 +519,7 @@ export function useEmbeddingsReindexProgress(
|
||||
send: sendCommand,
|
||||
} = useWs("embeddings_reindex_progress", "embeddingsReindexProgress");
|
||||
|
||||
const data = useMemo(
|
||||
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||
[payload],
|
||||
);
|
||||
const data = useDeepMemo(JSON.parse(payload as string));
|
||||
|
||||
useEffect(() => {
|
||||
let listener = undefined;
|
||||
@ -616,9 +553,8 @@ export function useAudioTranscriptionProcessState(
|
||||
send: sendCommand,
|
||||
} = useWs("audio_transcription_state", "audioTranscriptionState");
|
||||
|
||||
const data = useMemo(
|
||||
() => (payload ? (JSON.parse(payload as string) as string) : "idle"),
|
||||
[payload],
|
||||
const data = useDeepMemo(
|
||||
payload ? (JSON.parse(payload as string) as string) : "idle",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -651,10 +587,7 @@ export function useBirdseyeLayout(revalidateOnFocus: boolean = true): {
|
||||
send: sendCommand,
|
||||
} = useWs("birdseye_layout", "birdseyeLayout");
|
||||
|
||||
const data = useMemo(
|
||||
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||
[payload],
|
||||
);
|
||||
const data = useDeepMemo(JSON.parse(payload as string));
|
||||
|
||||
useEffect(() => {
|
||||
let listener = undefined;
|
||||
@ -751,14 +684,10 @@ export function useTrackedObjectUpdate(): {
|
||||
const {
|
||||
value: { payload },
|
||||
} = useWs("tracked_object_update", "");
|
||||
const parsed = useMemo(
|
||||
() =>
|
||||
payload
|
||||
? JSON.parse(payload as string)
|
||||
: { type: "", id: "", camera: "" },
|
||||
[payload],
|
||||
);
|
||||
return { payload: parsed };
|
||||
const parsed = payload
|
||||
? JSON.parse(payload as string)
|
||||
: { type: "", id: "", camera: "" };
|
||||
return { payload: useDeepMemo(parsed) };
|
||||
}
|
||||
|
||||
export function useNotifications(camera: string): {
|
||||
@ -801,14 +730,10 @@ export function useTriggers(): { payload: TriggerStatus } {
|
||||
const {
|
||||
value: { payload },
|
||||
} = useWs("triggers", "");
|
||||
const parsed = useMemo(
|
||||
() =>
|
||||
payload
|
||||
? JSON.parse(payload as string)
|
||||
: { name: "", camera: "", event_id: "", type: "", score: 0 },
|
||||
[payload],
|
||||
);
|
||||
return { payload: parsed };
|
||||
const parsed = payload
|
||||
? JSON.parse(payload as string)
|
||||
: { name: "", camera: "", event_id: "", type: "", score: 0 };
|
||||
return { payload: useDeepMemo(parsed) };
|
||||
}
|
||||
|
||||
export function useJobStatus(
|
||||
@ -820,9 +745,8 @@ export function useJobStatus(
|
||||
send: sendCommand,
|
||||
} = useWs("job_state", "jobState");
|
||||
|
||||
const jobData = useMemo(
|
||||
() => (payload && typeof payload === "string" ? JSON.parse(payload) : {}),
|
||||
[payload],
|
||||
const jobData = useDeepMemo(
|
||||
payload && typeof payload === "string" ? JSON.parse(payload) : {},
|
||||
);
|
||||
const currentJob = jobData[jobType] || null;
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type Update = {
|
||||
topic: string;
|
||||
payload: unknown;
|
||||
retain: boolean;
|
||||
};
|
||||
|
||||
export type WsSend = (update: Update) => void;
|
||||
|
||||
export const WsSendContext = createContext<WsSend | null>(null);
|
||||
@ -98,10 +98,10 @@ const TimeAgo: FunctionComponent<IProp> = ({
|
||||
return manualRefreshInterval;
|
||||
}
|
||||
|
||||
const elapsedMs = currentTime.getTime() - time;
|
||||
if (elapsedMs < 60000) {
|
||||
const currentTs = currentTime.getTime() / 1000;
|
||||
if (currentTs - time < 60) {
|
||||
return 1000; // refresh every second
|
||||
} else if (elapsedMs < 3600000) {
|
||||
} else if (currentTs - time < 3600) {
|
||||
return 60000; // refresh every minute
|
||||
} else {
|
||||
return 3600000; // refresh every hour
|
||||
|
||||
@ -1,70 +1,18 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Polygon } from "@/types/canvas";
|
||||
import { subscribeWsTopic, getWsTopicValue } from "@/api/ws";
|
||||
import { useWsState } from "@/api/ws";
|
||||
|
||||
/**
|
||||
* Hook to get enabled state for a polygon from websocket state.
|
||||
* Subscribes to all relevant per-polygon topics so it only re-renders
|
||||
* when one of those specific topics changes — not on every WS update.
|
||||
* Memoizes the lookup function to avoid unnecessary re-renders.
|
||||
*/
|
||||
export function usePolygonStates(polygons: Polygon[]) {
|
||||
// Build a stable sorted list of topics we need to watch
|
||||
const topics = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
polygons.forEach((polygon) => {
|
||||
const topic =
|
||||
polygon.type === "zone"
|
||||
? `${polygon.camera}/zone/${polygon.name}/state`
|
||||
: polygon.type === "motion_mask"
|
||||
? `${polygon.camera}/motion_mask/${polygon.name}/state`
|
||||
: `${polygon.camera}/object_mask/${polygon.name}/state`;
|
||||
set.add(topic);
|
||||
});
|
||||
return Array.from(set).sort();
|
||||
}, [polygons]);
|
||||
const wsState = useWsState();
|
||||
|
||||
// Stable key for the topic list so subscribe/getSnapshot stay in sync
|
||||
const topicsKey = topics.join("\0");
|
||||
|
||||
// Subscribe to all topics at once — re-subscribe only when the set changes
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => {
|
||||
const unsubscribes = topicsKey
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((topic) => subscribeWsTopic(topic, listener));
|
||||
return () => unsubscribes.forEach((unsub) => unsub());
|
||||
},
|
||||
[topicsKey],
|
||||
);
|
||||
|
||||
// Build a snapshot string from the current values of all topics.
|
||||
// useSyncExternalStore uses Object.is, so we return a primitive that
|
||||
// changes only when an observed topic's value changes.
|
||||
const getSnapshot = useCallback(() => {
|
||||
return topicsKey
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((topic) => `${topic}=${getWsTopicValue(topic) ?? ""}`)
|
||||
.join("\0");
|
||||
}, [topicsKey]);
|
||||
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
// Parse the snapshot into a lookup map
|
||||
// Create a memoized lookup map that only updates when relevant ws values change
|
||||
return useMemo(() => {
|
||||
// Build value map from snapshot
|
||||
const valueMap = new Map<string, unknown>();
|
||||
snapshot.split("\0").forEach((entry) => {
|
||||
const eqIdx = entry.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const topic = entry.slice(0, eqIdx);
|
||||
const val = entry.slice(eqIdx + 1) || undefined;
|
||||
valueMap.set(topic, val);
|
||||
}
|
||||
});
|
||||
|
||||
const stateMap = new Map<string, boolean>();
|
||||
|
||||
polygons.forEach((polygon) => {
|
||||
const topic =
|
||||
polygon.type === "zone"
|
||||
@ -73,7 +21,7 @@ export function usePolygonStates(polygons: Polygon[]) {
|
||||
? `${polygon.camera}/motion_mask/${polygon.name}/state`
|
||||
: `${polygon.camera}/object_mask/${polygon.name}/state`;
|
||||
|
||||
const wsValue = valueMap.get(topic);
|
||||
const wsValue = wsState[topic];
|
||||
const enabled =
|
||||
wsValue === "ON"
|
||||
? true
|
||||
@ -92,5 +40,5 @@ export function usePolygonStates(polygons: Polygon[]) {
|
||||
true
|
||||
);
|
||||
};
|
||||
}, [polygons, snapshot]);
|
||||
}, [polygons, wsState]);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user