Compare commits

..

5 Commits

Author SHA1 Message Date
Weblate (bot)
100df07d05
Merge a93b3e329c into a0b8271532 2026-03-11 05:40:22 +00:00
Hosted Weblate
a93b3e329c
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 71.4% (654 of 915 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1084 of 1084 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 73.9% (17 of 23 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 82.8% (387 of 467 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 20.5% (96 of 467 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 8.6% (94 of 1084 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 7.2% (34 of 467 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 65.2% (15 of 23 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 3.1% (34 of 1084 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 71.3% (653 of 915 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 82.8% (140 of 169 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 54.5% (12 of 22 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 52.0% (13 of 25 strings)

Co-authored-by: GuoQing Liu <842607283@qq.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: 郁闷的太子 <taiziccf@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-cameras/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-global/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-groups/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/config-validation/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-exports/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-settings/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-system/zh_Hans/
Translation: Frigate NVR/Config - Cameras
Translation: Frigate NVR/Config - Global
Translation: Frigate NVR/Config - Groups
Translation: Frigate NVR/Config - Validation
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-03-11 06:40:14 +01:00
Hosted Weblate
fb47ca00f1
Translated using Weblate (Albanian)
Currently translated at 30.0% (69 of 230 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Sali Maloku <sali.maloku94@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/common/sq/
Translation: Frigate NVR/common
2026-03-11 06:40:13 +01:00
Hosted Weblate
631f9c6ffd
Translated using Weblate (Catalan)
Currently translated at 100.0% (62 of 62 strings)

Co-authored-by: Eduardo Pastor Fernández <123eduardoneko123@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/ca/
Translation: Frigate NVR/views-events
2026-03-11 06:40:13 +01:00
Hosted Weblate
3afdd2aedd
Translated using Weblate (Romanian)
Currently translated at 100.0% (62 of 62 strings)

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: lukasig <lukasig@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/frigate-nvr/views-events/ro/
Translation: Frigate NVR/views-events
2026-03-11 06:40:13 +01:00
22 changed files with 365 additions and 883 deletions

View File

@ -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"

View File

@ -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 \

View File

@ -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 '';

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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
View File

@ -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",

View File

@ -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",

View 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) {

View File

@ -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": "已复制链接到剪贴板。",

View File

@ -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": "所有"
}

View File

@ -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": "片段名称"
}
}

View File

@ -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"
}
}

View File

@ -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": "原始遮罩"

View File

@ -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": "全局分辨率"
}
}
}

View File

@ -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>
);
}

View File

@ -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";

View File

@ -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;

View File

@ -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);

View File

@ -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

View File

@ -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]);
}