Compare commits

...

8 Commits

Author SHA1 Message Date
Weblate (bot)
7407fbe308
Merge 17472a8154 into 192aba901a 2026-03-11 14:03:21 +00:00
Hosted Weblate
17472a8154
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (25 of 25 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 98.9% (462 of 467 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (230 of 230 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (23 of 23 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% (62 of 62 strings)

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/common/zh_Hans/
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-events/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/common
Translation: Frigate NVR/views-events
Translation: Frigate NVR/views-exports
Translation: Frigate NVR/views-settings
Translation: Frigate NVR/views-system
2026-03-11 15:03:02 +01:00
Hosted Weblate
5cd8c7b9d6
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 15:03:01 +01:00
Hosted Weblate
81471ea9e8
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 15:03:00 +01:00
Hosted Weblate
61212b4f7a
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 15:02:59 +01:00
Josh Hawkins
192aba901a
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* refactor websockets to remove react-tracked

react 19 removed useReducer eager bailout, which broke react-tracked.

react-tracked works by wrapping state in a JavaScript Proxy. When a component reads state.someField, the proxy records that access. On the next state update, it compares only the fields each component actually touched and skips re-renders if those fields are unchanged. Under the hood, this relies on useReducer — and in React 18, useReducer had an "eager bail-out" that short-circuited rendering when the new state was === to the old state. React 19 removed that optimization, so every dispatch now schedules a render regardless, and the proxy comparison runs too late to prevent it.

useSyncExternalStore is a React primitive (added in 18, stable in 19) designed for exactly this pattern: subscribing to an external store:

useSyncExternalStore(
  subscribe,   // (listener) => unsubscribe — called when the store changes
  getSnapshot  // () => value — returns the current value for this subscriber
)

React calls getSnapshot during render and compares the result with Object.is. If the value is the same reference, the component bails out — no re-render. The key difference from react-tracked is that this bail-out is built into React's reconciler, not bolted on via proxy tricks and useReducer.

The per-topic subscription model makes this efficient. Instead of one global store where every subscriber has to check if their fields changed, each useWs("some/topic", ...) call subscribes only to that topic's listener set. When a message arrives for front_door/detect/state, only components subscribed to that exact topic get their listener fired → React calls their getSnapshot → Object.is compares the value → bail-out if unchanged. Components watching back_yard/detect/state are never even notified.

* remove react-tracked and react-use-websocket

* refactor usePolygonStates to use ws topic subscription

* fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds)

older events now correctly refresh every minute/hour instead of every second

* simplify

* clean up

* don't resend onconnect

* clean up

* remove patch
2026-03-11 09:02:51 -05:00
Josh Hawkins
947ddfa542
http2 (#22379) 2026-03-11 08:32:16 -05:00
GuoQing Liu
9eb037c369
Initial commit for AXERA AI accelerators (#22206)
* feat: Initial AXERA detector

* chore: update pip install URL for axengine package

* Update docker/main/Dockerfile

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>

* Update docs/docs/configuration/object_detectors.md

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update AXERA section in installation.md

Removed details section for AXERA accelerators in installation guide.

* Update axmodel download URL to Hugging Face

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Co-authored-by: shizhicheng <shizhicheng@axera-tech.com>
2026-03-11 06:49:28 -06:00
28 changed files with 4012 additions and 325 deletions

View File

@ -266,6 +266,12 @@ 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,6 +73,7 @@ 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,6 +63,9 @@ 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,6 +49,11 @@ 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.
@ -1478,6 +1483,41 @@ 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.
@ -1571,12 +1611,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 libgl1 && rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.8.0 /uv /bin/
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/
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.1 onnxscript
RUN uv pip install --system onnx==1.18.0 onnxruntime onnx-simplifier==0.4.* 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,6 +103,10 @@ 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
@ -288,6 +292,14 @@ 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.
@ -308,4 +320,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,6 +439,39 @@ 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

@ -0,0 +1,86 @@
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,8 +72,6 @@
"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",
@ -4400,7 +4398,6 @@
"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",
@ -4475,7 +4472,6 @@
"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",
@ -5149,7 +5145,6 @@
"integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}
@ -5159,7 +5154,6 @@
"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"
}
@ -5170,7 +5164,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -5300,7 +5293,6 @@
"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",
@ -5593,7 +5585,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5755,7 +5746,6 @@
"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",
@ -5966,7 +5956,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.5.4",
@ -6467,7 +6456,6 @@
"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"
@ -6907,7 +6895,6 @@
"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",
@ -6963,7 +6950,6 @@
"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"
},
@ -7887,7 +7873,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.23.2"
},
@ -8533,8 +8518,7 @@
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/levn": {
"version": "0.4.1",
@ -9683,8 +9667,7 @@
"version": "0.52.2",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
"integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/monaco-languageserver-types": {
"version": "0.4.0",
@ -10392,7 +10375,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@ -10527,7 +10509,6 @@
"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"
},
@ -10676,11 +10657,6 @@
"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",
@ -10733,7 +10709,6 @@
"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"
}
@ -10798,7 +10773,6 @@
"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"
},
@ -10860,7 +10834,6 @@
"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"
},
@ -11115,29 +11088,6 @@
"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",
@ -11549,8 +11499,7 @@
"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",
"peer": true
"license": "MIT"
},
"node_modules/scroll-into-view-if-needed": {
"version": "3.1.0",
@ -12049,7 +11998,6 @@
"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",
@ -12232,7 +12180,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -12411,7 +12358,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -12627,15 +12573,6 @@
}
}
},
"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",
@ -12771,7 +12708,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -12896,7 +12832,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -12910,7 +12845,6 @@
"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,8 +78,6 @@
"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

@ -1,23 +0,0 @@
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

@ -82,6 +82,9 @@
"back": "Enrere",
"empty": "No hi ha cap vista prèvia disponible",
"noPreview": "Vista prèvia no disponible",
"seekAria": "Cerca el reproductor {{camera}} a {{time}}"
"seekAria": "Cerca el reproductor {{camera}} a {{time}}",
"filter": "Filtre",
"filterDesc": "Seleccioneu àrees per a mostrar només clips amb moviment en aquestes regions.",
"filterClear": "Neteja"
}
}

View File

@ -82,6 +82,9 @@
"back": "Înapoi",
"empty": "Nicio previzualizare disponibilă",
"noPreview": "Previzualizare indisponibilă",
"seekAria": "Derulează player-ul {{camera}} la {{time}}"
"seekAria": "Derulează player-ul {{camera}} la {{time}}",
"filter": "Filtru",
"filterDesc": "Selectează zonele pentru a afișa doar clipurile cu mișcare în acele regiuni.",
"filterClear": "Șterge"
}
}

View File

@ -1 +1,85 @@
{}
{
"time": {
"never": "Kurrë",
"ago": "{{timeAgo}} më parë",
"today": "Sot",
"yesterday": "Dje",
"last7": "7 ditët e fundit",
"last14": "14 ditët e fundit",
"last30": "30 ditët e fundit",
"thisWeek": "Këtë javë",
"lastWeek": "Javën e kaluar",
"thisMonth": "Këtë muaj",
"lastMonth": "Muajin e kaluar",
"5minutes": "5 minuta",
"10minutes": "10 minuta",
"30minutes": "30 minuta",
"1hour": "1 orë",
"12hours": "12 orë",
"24hours": "24 orë",
"pm": "mbasdite",
"am": "paradite"
},
"unit": {
"data": {
"kbps": "kB/s",
"mbps": "MB/s",
"gbps": "GB/s",
"kbph": "kB/orë",
"mbph": "MB/orë",
"gbph": "GB/orë"
}
},
"label": {
"back": "Kthehu pas",
"hide": "Fsheh {{item}}",
"show": "Shfaq {{item}}",
"ID": "ID",
"none": "Asnjë",
"all": "Të gjitha",
"other": "Tjetër"
},
"list": {
"two": "{{0}} dhe {{1}}",
"many": "{{items}}, dhe {{last}}",
"separatorWithSpace": ", "
},
"field": {
"optional": "Opsionale",
"internalID": "ID-ja e brendshme që Frigate përdor në konfigurim dhe në databazë"
},
"button": {
"add": "Shto",
"apply": "Vendos",
"applying": "Duke vendosur…",
"reset": "Rivendos",
"undo": "Zhbëj",
"done": "Përfunduar",
"enabled": "Aktivizuar",
"enable": "Aktivizo",
"disabled": "Deaktivizuar",
"disable": "Deaktivizo",
"save": "Ruaj",
"saving": "Duke ruajtur…",
"cancel": "Anulo",
"close": "Mbyll",
"copy": "Kopjo",
"copiedToClipboard": "Kopjuar në clipboard",
"back": "Pas",
"history": "Historia",
"fullscreen": "Ekran i plotë",
"exitFullscreen": "Dil nga ekrani i plotë",
"pictureInPicture": "Fotografi në fotografi (PiP)",
"twoWayTalk": "Komunikim dyanësor",
"cameraAudio": "Zëri i kamerës",
"on": "Aktiv",
"off": "Joaktiv",
"edit": "Ndrysho",
"copyCoordinates": "Kopjo koordinatat",
"delete": "Fshij",
"yes": "Po",
"no": "Jo",
"download": "Shkarko",
"info": "Info"
}
}

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,7 +156,18 @@
"next": "下一个",
"cameraAudio": "摄像头音频",
"twoWayTalk": "双向对话",
"continue": "继续"
"continue": "继续",
"add": "添加",
"applying": "应用中…",
"undo": "撤销",
"copiedToClipboard": "已复制到剪贴板",
"modified": "已修改",
"overridden": "已覆盖",
"resetToGlobal": "重置为全局",
"resetToDefault": "重置为默认",
"saveAll": "保存全部",
"savingAll": "保存全部中…",
"undoAll": "撤销全部"
},
"menu": {
"system": "系统",
@ -170,7 +181,7 @@
"en": "英语 (English)",
"zhCN": "简体中文",
"withSystem": {
"label": "使用系统语言设置"
"label": "使用系统语言设置"
},
"hi": "印地语 (हिन्दी)",
"es": "西班牙语 (Español)",
@ -192,15 +203,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)",
@ -209,7 +220,7 @@
"gl": "加利西亚语 (Galego)",
"id": "印度尼西亚语 (Bahasa Indonesia)",
"ur": "乌尔都语 (اردو)",
"hr": "克罗地亚语Hrvatski"
"hr": "克罗地亚语 (Hrvatski)"
},
"appearance": "外观",
"darkMode": {
@ -258,7 +269,9 @@
"title": "用户"
},
"restart": "重启 Frigate",
"classification": "目标分类"
"classification": "目标分类",
"actions": "操作",
"chat": "聊天"
},
"toast": {
"copyUrlToClipboard": "已复制链接到剪贴板。",

View File

@ -64,5 +64,28 @@
"normalActivity": "正常",
"needsReview": "需要核查",
"securityConcern": "安全隐患",
"select_all": "所有"
"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": "清除"
}
}

View File

@ -11,13 +11,27 @@
},
"toast": {
"error": {
"renameExportFailed": "重命名导出失败:{{errorMessage}}"
"renameExportFailed": "重命名导出失败:{{errorMessage}}",
"assignCaseFailed": "更新合集分配失败:{{errorMessage}}"
}
},
"tooltip": {
"shareExport": "分享导出",
"downloadVideo": "下载视频",
"editName": "编辑名称",
"deleteExport": "删除导出"
"deleteExport": "删除导出",
"assignToCase": "加入合集"
},
"headings": {
"uncategorizedExports": "未分类导出项",
"cases": "合集"
},
"caseDialog": {
"nameLabel": "合集名称",
"title": "加入合集",
"description": "选择现有合集或创建新合集。",
"selectLabel": "合集",
"newCaseOption": "创建新合集",
"descriptionLabel": "描述"
}
}

View File

@ -7,12 +7,14 @@
"masksAndZones": "遮罩和区域编辑器 - Frigate",
"motionTuner": "画面变动调整 - Frigate",
"object": "调试 - Frigate",
"general": "页面设置 - Frigate",
"general": "配置文件设置 - Frigate",
"frigatePlus": "Frigate+ 设置 - Frigate",
"notifications": "通知设置 - Frigate",
"enrichments": "增强功能设置 - Frigate",
"cameraManagement": "管理摄像头 - Frigate",
"cameraReview": "摄像头核查设置 - Frigate"
"cameraReview": "摄像头核查设置 - Frigate",
"globalConfig": "全局配置 - Frigate",
"cameraConfig": "摄像头配置 - Frigate"
},
"menu": {
"ui": "界面设置",
@ -28,7 +30,8 @@
"triggers": "触发器",
"roles": "权限组",
"cameraManagement": "管理",
"cameraReview": "核查"
"cameraReview": "核查",
"globalDetect": "目标检测"
},
"dialog": {
"unsavedChanges": {
@ -405,7 +408,7 @@
},
"restart_required": "需要重启(遮罩与区域已修改)",
"motionMaskLabel": "画面变动遮罩 {{number}}",
"objectMaskLabel": "目标/物体遮罩 {{number}}{{label}}"
"objectMaskLabel": "目标/物体遮罩 {{number}}"
},
"motionDetectionTuner": {
"title": "画面变动检测调整",
@ -517,7 +520,7 @@
"actions": "操作",
"role": "权限组",
"noUsers": "未找到用户。",
"changeRole": "更改用户角色",
"changeRole": "更改用户权限组",
"password": "修改密码",
"deleteUser": "删除用户"
},
@ -570,7 +573,7 @@
},
"createUser": {
"title": "创建新用户",
"desc": "创建一个新用户账户,并指定一个角色以控制访问 Frigate UI 的权限。",
"desc": "创建一个新用户账户,并指定一个权限组以控制访问 Frigate 页面的权限。",
"usernameOnlyInclude": "用户名只能包含字母、数字和 _",
"confirmPassword": "请确认你的密码"
},
@ -934,7 +937,7 @@
},
"deleteRole": {
"title": "删除权限组",
"desc": "此操作无法撤销。这将永久删除该权限组,并将所有拥有此角色的用户分配到 “成员” 权限组,该权限组将赋予用户查看所有摄像头的权限。",
"desc": "此操作无法撤销。这将永久删除该权限组,并将所有拥有此权限组的用户分配到 “成员” view权限组,该权限组将赋予用户查看所有摄像头的权限。",
"warn": "你确定要删除权限组 <strong>{{role}}</strong> 吗?",
"deleting": "删除中…"
},

View File

@ -7,7 +7,8 @@
"logs": {
"frigate": "Frigate 日志 - Frigate",
"go2rtc": "Go2RTC 日志 - Frigate",
"nginx": "Nginx 日志 - Frigate"
"nginx": "Nginx 日志 - Frigate",
"websocket": "消息日志 - Frigate"
}
},
"title": "系统",
@ -33,6 +34,13 @@
"fetchingLogsFailed": "获取日志出错:{{errorMessage}}",
"whileStreamingLogs": "流式传输日志时出错:{{errorMessage}}"
}
},
"websocket": {
"label": "消息",
"pause": "暂停",
"filter": {
"lpr": "车牌识别"
}
}
},
"general": {

View File

@ -1 +1,32 @@
{}
{
"minimum": "必须至少为 {{limit}}",
"maximum": "最大值不能超过 {{limit}}",
"exclusiveMinimum": "必须大于 {{limit}}",
"exclusiveMaximum": "必须小于 {{limit}}",
"minLength": "长度至少为 {{limit}} 个字符",
"maxLength": "长度最多为 {{limit}} 个字符",
"minItems": "至少包含 {{limit}} 项",
"maxItems": "最多包含 {{limit}} 项",
"pattern": "格式无效",
"required": "此字段为必填项",
"type": "值类型无效",
"ffmpeg": {
"inputs": {
"detectRequired": "必须至少有一个输入流分配为“检测”功能。",
"rolesUnique": "每个功能只能分配给一个输入流。",
"hwaccelDetectOnly": "只有分配了检测功能的输入流才能定义硬件加速参数。"
}
},
"enum": "必须是允许的值之一",
"const": "值与预期的常量不匹配",
"uniqueItems": "所有项必须唯一",
"format": "格式无效",
"additionalProperties": "不允许未知属性",
"oneOf": "必须完全匹配一个允许的模式",
"anyOf": "必须至少匹配一个允许的模式",
"proxy": {
"header_map": {
"roleHeaderRequired": "配置权限组映射时需要的 role 请求头。"
}
}
}

View File

@ -1 +1,936 @@
{}
{
"label": "摄像头配置",
"name": {
"label": "摄像头名称",
"description": "必须填写摄像头名称"
},
"friendly_name": {
"label": "别名",
"description": "摄像头别名将用于展示在页面中"
},
"enabled": {
"label": "开启",
"description": "开启"
},
"audio": {
"label": "音频事件",
"description": "此摄像头的音频事件检测设置。",
"enabled": {
"label": "开启音频检测",
"description": "开启或禁用此摄像头的音频事件检测。"
},
"max_not_heard": {
"label": "结束超时",
"description": "在结束音频事件之前,未检测到配置的音频类型的秒数。"
},
"num_threads": {
"label": "检测线程",
"description": "用于音频检测处理的线程数量。"
},
"min_volume": {
"label": "最小音量",
"description": "运行音频检测所需的最小 RMS 音量阈值;数值越低灵敏度越高(例如 200 高灵敏度500 中等1000 低灵敏度)。"
},
"listen": {
"label": "监听类型",
"description": "要检测的音频事件类型列表例如bark、fire_alarm、scream、speech、yell。"
},
"filters": {
"label": "音频过滤器",
"description": "按音频类型的过滤器设置,如用于减少误报的置信度阈值。"
},
"enabled_in_config": {
"label": "原始音频状态",
"description": "指示原始静态配置文件中是否启用了音频检测。"
}
},
"audio_transcription": {
"label": "音频转录",
"description": "用于事件和实时字幕的实时和语音音频转录设置。",
"enabled": {
"label": "开启转录",
"description": "开启或关闭手动触发的音频事件转写。"
},
"enabled_in_config": {
"label": "原始转写状态"
},
"live_enabled": {
"label": "实时监控转写",
"description": "在接收到音频时开启实时监控持续转写。"
}
},
"birdseye": {
"label": "鸟瞰图",
"description": "将多路摄像头画面合并为统一布局的鸟瞰合成视图设置。",
"enabled": {
"label": "开启鸟瞰图",
"description": "开启或关闭鸟瞰图功能。"
},
"mode": {
"label": "追踪模式",
"description": "在鸟瞰视图中包含摄像头的模式:'objects'(目标)、'motion'(动作)或 'continuous'(持续)。"
},
"order": {
"label": "排序位置",
"description": "用于控制摄像头在鸟瞰视图布局中排序位置的数值。"
}
},
"detect": {
"label": "目标检测",
"description": "用于运行目标检测、初始化追踪器的检测模块设置。",
"enabled": {
"label": "开启检测",
"description": "开启或关闭该摄像头的目标检测。如需运行目标追踪,必须先开启检测。"
},
"height": {
"label": "检测画面高度",
"description": "用于配置检测流的画面高度(像素);留空则使用原始视频流分辨率。"
},
"width": {
"label": "检测画面宽度",
"description": "用于配置检测流的画面宽度(像素);留空则使用原始视频流分辨率。"
},
"fps": {
"label": "检测帧率",
"description": "检测时希望使用的帧率数值越低CPU 占用越小(推荐值为 5仅在追踪极高速运动的目标时才设置更高数值最高不建议超过 10。"
},
"min_initialized": {
"label": "最小初始化帧数",
"description": "创建追踪目标前,需要连续检测到目标的次数。数值越大,错误触发的追踪越少。默认值为帧率除以 2。"
},
"max_disappeared": {
"label": "最大消失帧数",
"description": "追踪目标在连续多少帧未被检测到时,将被判定为已消失。"
},
"stationary": {
"label": "静止目标配置",
"description": "用于检测和管理长时间静止目标的相关设置。",
"interval": {
"label": "静止间隔",
"description": "设置每隔多少帧执行一次检测,用于确认目标是否处于静止状态。"
},
"threshold": {
"label": "静止阈值",
"description": "目标需要连续多少帧位置不变,才会被标记为静止状态。"
},
"max_frames": {
"label": "最大帧数",
"description": "限制静止目标最大追踪时长(以帧数为单位),超过将会停止追踪。",
"default": {
"label": "默认最大帧数",
"description": "停止追踪前,用于追踪静止目标的默认最大帧数。"
},
"objects": {
"label": "目标最大帧数",
"description": "可对不同类型目标分别设置静止追踪的最大帧数(覆盖全局设置)。"
}
},
"classifier": {
"label": "开启视觉分类器",
"description": "使用视觉分类器,即使检测框有轻微抖动,也能准确判断物体是真的静止。"
}
},
"annotation_offset": {
"label": "标记偏移量",
"description": "检测标记的时间偏移量(毫秒),用于让时间轴上的检测框与录像画面更精准对齐;可设置为正数或负数。"
}
},
"face_recognition": {
"label": "人脸识别",
"description": "该摄像头的人脸检测与识别设置。",
"enabled": {
"label": "开启人脸识别",
"description": "开启或关闭人脸识别。"
},
"min_area": {
"label": "最小人脸区域",
"description": "需要尝试进行人脸识别的人脸检测框最小大小(像素)。"
}
},
"ffmpeg": {
"label": "FFmpeg",
"description": "FFmpeg 编解码相关设置,包含可执行文件路径、命令行参数、硬件加速选项,以及按不同功能划分的输出参数。",
"path": {
"label": "FFmpeg 路径",
"description": "要使用的 FFmpeg 可执行文件路径,或版本别名(如 \"5.0\" 或 \"7.0\")。"
},
"global_args": {
"label": "FFmpeg 全局参数",
"description": "传递给 FFmpeg 进程的全局参数。"
},
"hwaccel_args": {
"label": "硬件加速参数",
"description": "用于 FFmpeg 的硬件加速参数。建议使用对应硬件厂商的预设配置。"
},
"input_args": {
"label": "输入参数",
"description": "应用于 FFmpeg 输入视频流的输入参数。"
},
"output_args": {
"label": "输出参数",
"description": "用于不同 FFmpeg 功能(如检测、录制)的默认输出参数。",
"detect": {
"label": "检测输出参数",
"description": "检测功能视频流的默认输出参数。"
},
"record": {
"label": "录制输出参数",
"description": "录制功能视频流的默认输出参数。"
}
},
"retry_interval": {
"label": "FFmpeg 重试时间",
"description": "摄像头视频流异常断开后,重新连接前的等待时间。默认为 10 秒。"
},
"apple_compatibility": {
"label": "Apple 兼容性",
"description": "录制 H.265 视频时启用 HEVC 标记,以提升对 Apple 设备播放的兼容性。"
},
"gpu": {
"label": "GPU 索引",
"description": "在启用硬件加速时,默认使用的 GPU 索引。"
},
"inputs": {
"label": "摄像头输入视频流",
"description": "该摄像头的所有输入流配置列表(包含路径和功能)。",
"path": {
"label": "输入路径",
"description": "摄像头输入视频流的地址或路径。"
},
"roles": {
"label": "输入流功能",
"description": "定义该视频流的功能。"
},
"global_args": {
"label": "FFmpeg 全局参数",
"description": "该输入视频流使用的 FFmpeg 全局通用参数。"
},
"hwaccel_args": {
"label": "硬件加速参数",
"description": "该输入视频流的硬件加速参数。"
},
"input_args": {
"label": "输入参数",
"description": "该视频流特定的输入参数。"
}
}
},
"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。"
}
},
"notifications": {
"label": "通知",
"enabled": {
"label": "开启通知"
},
"email": {
"label": "通知邮箱",
"description": "用于推送通知或某些通知提供商要求的邮箱地址。"
},
"cooldown": {
"label": "冷却时间",
"description": "通知之间的冷却时间(秒),以避免向收件人发送垃圾信息。"
},
"enabled_in_config": {
"label": "原始通知状态",
"description": "指示原始静态配置中是否启用了通知。"
}
},
"live": {
"label": "实时监控播放",
"streams": {
"label": "实时监控流名称",
"description": "配置的流名称到用于实时监控播放的 restream/go2rtc 名称的映射。"
},
"height": {
"label": "实时监控高度",
"description": "在 Web UI 中渲染 jsmpeg 实时监控流的高度(像素);必须小于等于检测流高度。"
},
"quality": {
"label": "实时监控质量",
"description": "jsmpeg 流的编码质量1 最高31 最低)。"
}
},
"motion": {
"label": "画面变动检测",
"enabled": {
"label": "开启画面变动检测",
"description": "开启或关闭此摄像头的画面变动检测。"
},
"threshold": {
"label": "画面变动阈值",
"description": "画面变动检测器使用的像素差异阈值;数值越高灵敏度越低(范围 1-255。"
},
"lightning_threshold": {
"label": "闪电阈值",
"description": "用于检测和忽略短暂闪电闪烁的阈值(数值越低越敏感,范围 0.3 到 1.0)。这不会完全阻止画面变动检测;只是当超过阈值时检测器会停止分析额外的帧。在此类事件期间仍会创建基于画面变动的录像。"
},
"skip_motion_threshold": {
"label": "跳过画面变动阈值",
"description": "如果单帧中图像变化超过此比例,检测器将返回无画面变动框并立即重新校准。这可以节省 CPU 并减少闪电、风暴等情况下的误报,但可能会错过真实事件,如 PTZ 摄像头自动追踪目标。权衡的是丢弃几兆字节的录像与查看几个短片之间的取舍。范围 0.0 到 1.0。"
},
"improve_contrast": {
"label": "改善对比度",
"description": "在画面变动分析之前对帧应用对比度改善以帮助检测。"
},
"contour_area": {
"label": "轮廓区域",
"description": "画面变动轮廓被计入所需的最小轮廓区域(像素)。"
},
"delta_alpha": {
"label": "Delta alpha",
"description": "用于画面变动计算的帧差异中使用的 alpha 混合因子。"
},
"frame_alpha": {
"label": "画面 alpha 通道",
"description": "画面变动预处理时混合画面所使用的 alpha 值。"
},
"frame_height": {
"label": "画面高度",
"description": "计算画面变动时缩放画面的高度(像素)。"
},
"mask": {
"label": "遮罩坐标",
"description": "定义用于包含/排除区域的画面变动遮罩多边形的有序 x,y 坐标。"
},
"mqtt_off_delay": {
"label": "MQTT 关闭延迟",
"description": "在发布 MQTT 'off' 状态之前,最后一次画面变动后等待的秒数。"
},
"enabled_in_config": {
"label": "原始画面变动状态",
"description": "指示原始静态配置中是否启用了画面变动检测。"
},
"raw_mask": {
"label": "原始遮罩"
},
"description": "此摄像头的默认画面变动检测设置。"
},
"objects": {
"label": "目标",
"description": "目标追踪默认设置,包括要追踪的标签和按目标的过滤器。",
"track": {
"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": "原始遮罩"
}
},
"mask": {
"label": "目标遮罩",
"description": "用于防止在指定区域进行目标检测的遮罩多边形。"
},
"raw_mask": {
"label": "原始遮罩"
},
"genai": {
"label": "GenAI 目标配置",
"description": "用于描述追踪目标和发送帧进行生成的 GenAI 选项。",
"enabled": {
"label": "开启 GenAI",
"description": "默认启用 GenAI 生成追踪目标的描述。"
},
"use_snapshot": {
"label": "使用快照",
"description": "使用目标快照而不是缩略图进行 GenAI 描述生成。"
},
"prompt": {
"label": "字幕提示",
"description": "使用 GenAI 生成描述时使用的默认提示模板。"
},
"object_prompts": {
"label": "目标提示",
"description": "用于自定义特定标签的 GenAI 输出的按目标提示。"
},
"objects": {
"label": "GenAI 目标",
"description": "默认发送给 GenAI 的目标标签列表。"
},
"required_zones": {
"label": "必需区域",
"description": "目标必须进入才能符合 GenAI 描述生成条件的区域。"
},
"debug_save_thumbnails": {
"label": "保存缩略图",
"description": "保存发送给 GenAI 的缩略图用于调试和核查。"
},
"send_triggers": {
"label": "GenAI 触发器",
"description": "定义何时应将帧发送给 GenAI结束时、更新后等。",
"tracked_object_end": {
"label": "结束时发送",
"description": "当追踪目标结束时向 GenAI 发送请求。"
},
"after_significant_updates": {
"label": "早期 GenAI 触发器",
"description": "在追踪目标进行指定次数的重大更新后向 GenAI 发送请求。"
}
},
"enabled_in_config": {
"label": "原始 GenAI 状态",
"description": "指示原始静态配置中是否启用了 GenAI。"
}
}
},
"record": {
"label": "录像",
"enabled": {
"label": "开启录像",
"description": "开启或关闭此摄像头的录像。"
},
"expire_interval": {
"label": "录像清理间隔",
"description": "清理过期录像片段的间隔分钟数。"
},
"continuous": {
"label": "持续保留",
"description": "无论是否有追踪目标或动作,保留录像的天数。如果只想保留警报和检测的录像,请设置为 0。",
"days": {
"label": "保留天数",
"description": "保留录像的天数。"
}
},
"motion": {
"label": "动作保留",
"description": "无论是否有追踪目标,由动作触发的录像保留天数。如果只想保留警报和检测的录像,请设置为 0。",
"days": {
"label": "保留天数",
"description": "保留录像的天数。"
}
},
"detections": {
"label": "检测保留",
"description": "检测事件的录像保留设置,包括前后捕获时长。",
"pre_capture": {
"label": "前捕获秒数",
"description": "检测事件之前包含在录像中的秒数。"
},
"post_capture": {
"label": "后捕获秒数",
"description": "检测事件之后包含在录像中的秒数。"
},
"retain": {
"label": "事件保留",
"description": "检测事件录像的保留设置。",
"days": {
"label": "保留天数",
"description": "保留检测事件录像的天数。"
},
"mode": {
"label": "保留模式",
"description": "保留模式all保存所有片段、motion保存有动作的片段或 active_objects保存有活动目标的片段。"
}
}
},
"alerts": {
"label": "警报保留",
"description": "警报事件的录像保留设置,包括前后捕获时长。",
"pre_capture": {
"label": "前捕获秒数",
"description": "检测事件之前包含在录像中的秒数。"
},
"post_capture": {
"label": "后捕获秒数",
"description": "检测事件之后包含在录像中的秒数。"
},
"retain": {
"label": "事件保留",
"description": "检测事件录像的保留设置。",
"days": {
"label": "保留天数",
"description": "保留检测事件录像的天数。"
},
"mode": {
"label": "保留模式",
"description": "保留模式all保存所有片段、motion保存有动作的片段或 active_objects保存有活动目标的片段。"
}
}
},
"export": {
"label": "导出配置",
"description": "导出录像时使用的设置,如延时摄影和硬件加速。",
"hwaccel_args": {
"label": "导出硬件加速参数",
"description": "用于导出/转码操作的硬件加速参数。"
}
},
"preview": {
"label": "预览配置",
"description": "控制 UI 中显示的录像预览质量的设置。",
"quality": {
"label": "预览质量",
"description": "预览质量级别very_low、low、medium、high、very_high。"
}
},
"enabled_in_config": {
"label": "原始录像状态",
"description": "指示原始静态配置中是否启用了录像。"
},
"description": "此摄像头的录像和保留设置。"
},
"review": {
"label": "核查",
"alerts": {
"label": "警报配置",
"description": "哪些追踪目标生成警报以及如何保留警报的设置。",
"enabled": {
"label": "开启警报",
"description": "开启或关闭此摄像头的警报生成。"
},
"labels": {
"label": "警报标签",
"description": "符合警报条件的目标标签列表例如car、person。"
},
"required_zones": {
"label": "必需区域",
"description": "目标必须进入才能被视为警报的区域;留空则允许任何区域。"
},
"enabled_in_config": {
"label": "原始警报状态",
"description": "追踪原始静态配置中是否启用了警报。"
},
"cutoff_time": {
"label": "警报截止时间",
"description": "在没有引起警报的活动后等待多少秒后截止警报。"
}
},
"detections": {
"label": "检测配置",
"description": "创建检测事件(非警报)以及保留多长时间的设置。",
"enabled": {
"label": "开启检测",
"description": "开启或关闭此摄像头的检测事件。"
},
"labels": {
"label": "检测标签",
"description": "符合检测事件条件的目标标签列表。"
},
"required_zones": {
"label": "必需区域",
"description": "目标必须进入才能被视为检测的区域;留空则允许任何区域。"
},
"cutoff_time": {
"label": "检测截止时间",
"description": "在没有引起检测的活动后等待多少秒后截止检测。"
},
"enabled_in_config": {
"label": "原始检测状态",
"description": "追踪原始静态配置中是否启用了检测。"
}
},
"genai": {
"label": "GenAI 配置",
"description": "控制使用生成式 AI 为核查项生成描述和摘要。",
"enabled": {
"label": "开启 GenAI 描述",
"description": "为核查项启用或禁用 GenAI 生成的描述和摘要。"
},
"alerts": {
"label": "为警报开启 GenAI",
"description": "使用 GenAI 为警报项生成描述。"
},
"detections": {
"label": "为检测开启 GenAI",
"description": "使用 GenAI 为检测项生成描述。"
},
"image_source": {
"label": "核查图像来源",
"description": "发送给 GenAI 的图像来源('preview' 或 'recordings''recordings' 使用更高质量的帧但消耗更多 token。"
},
"additional_concerns": {
"label": "额外关注事项",
"description": "GenAI 在评估此摄像头活动时应考虑的额外关注事项或备注列表。"
},
"debug_save_thumbnails": {
"label": "保存缩略图",
"description": "保存发送给 GenAI 提供商的缩略图用于调试和核查。"
},
"enabled_in_config": {
"label": "原始 GenAI 状态",
"description": "追踪原始静态配置中是否启用了 GenAI 核查。"
},
"preferred_language": {
"label": "首选语言",
"description": "向 GenAI 提供商请求生成响应的首选语言。"
},
"activity_context_prompt": {
"label": "活动上下文提示",
"description": "描述什么是和什么不是可疑活动的自定义提示,为 GenAI 摘要提供上下文。"
}
},
"description": "控制此摄像头的警报、检测和 GenAI 核查摘要的设置,用于 UI 和存储。"
},
"snapshots": {
"label": "快照",
"enabled": {
"label": "开启快照",
"description": "开启或关闭此摄像头的快照保存。"
},
"clean_copy": {
"label": "保存干净副本",
"description": "除了带注释的快照外,还保存一份不带注释的干净快照副本。"
},
"timestamp": {
"label": "时间戳叠加",
"description": "在保存的快照上叠加时间戳。"
},
"bounding_box": {
"label": "边界框叠加",
"description": "在保存的快照上绘制追踪目标的边界框。"
},
"crop": {
"label": "裁剪快照",
"description": "将保存的快照裁剪到检测到的目标边界框。"
},
"required_zones": {
"label": "必需区域",
"description": "目标必须进入才能保存快照的区域。"
},
"height": {
"label": "快照高度",
"description": "将保存的快照调整到的目标高度(像素);留空则保持原始大小。"
},
"retain": {
"label": "快照保留",
"description": "保存快照的保留设置,包括默认天数和按目标覆盖。",
"default": {
"label": "默认保留",
"description": "保留快照的默认天数。"
},
"mode": {
"label": "保留模式",
"description": "保留模式all保存所有片段、motion保存有动作的片段或 active_objects保存有活动目标的片段。"
},
"objects": {
"label": "目标保留",
"description": "按目标覆盖的快照保留天数。"
}
},
"quality": {
"label": "JPEG 质量",
"description": "保存快照的 JPEG 编码质量0-100。"
},
"description": "此摄像头保存的追踪目标 JPEG 快照设置。"
},
"timestamp_style": {
"label": "时间戳样式",
"position": {
"label": "时间戳位置",
"description": "时间戳在图像上的位置tl/tr/bl/br。"
},
"format": {
"label": "时间戳格式",
"description": "用于时间戳的日期时间格式字符串Python 日期时间格式代码)。"
},
"color": {
"label": "时间戳颜色",
"description": "时间戳文本的 RGB 颜色值(所有值 0-255。",
"red": {
"label": "红色",
"description": "时间戳颜色的红色分量0-255。"
},
"green": {
"label": "绿色",
"description": "时间戳颜色的绿色分量0-255。"
},
"blue": {
"label": "蓝色",
"description": "时间戳颜色的蓝色分量0-255。"
}
},
"thickness": {
"label": "时间戳粗细",
"description": "时间戳文本的线条粗细。"
},
"effect": {
"label": "时间戳效果",
"description": "时间戳文本的视觉效果none、solid、shadow。"
},
"description": "应用于录像和快照的实时监控流中时间戳的样式选项。"
},
"semantic_search": {
"label": "语义搜索",
"triggers": {
"label": "触发器",
"description": "摄像头特定语义搜索触发器的操作和匹配条件。",
"friendly_name": {
"label": "友好名称",
"description": "在 UI 中为此触发器显示的可选友好名称。"
},
"enabled": {
"label": "开启此触发器",
"description": "启用或禁用此语义搜索触发器。"
},
"type": {
"label": "触发器类型",
"description": "触发器类型:'thumbnail'(与图像匹配)或 'description'(与文本匹配)。"
},
"data": {
"label": "触发器内容",
"description": "要与追踪目标匹配的文本短语或缩略图 ID。"
},
"threshold": {
"label": "触发器阈值",
"description": "激活此触发器所需的最小相似度分数0-1。"
},
"actions": {
"label": "触发器操作",
"description": "触发器匹配时要执行的操作列表通知、sub_label、属性。"
}
},
"description": "语义搜索设置,用于构建和查询目标嵌入以查找相似项目。"
},
"lpr": {
"label": "车牌识别",
"description": "车牌识别设置,包括检测阈值、格式化和已知车牌。",
"enabled": {
"label": "开启 LPR"
},
"min_area": {
"label": "最小车牌区域",
"description": "尝试识别所需的最小车牌区域(像素)。"
},
"enhancement": {
"label": "增强级别",
"description": "在 OCR 之前应用于车牌裁剪的增强级别0-10较高的值可能不总是改善结果5 以上的级别可能仅适用于夜间车牌,应谨慎使用。"
},
"expire_time": {
"label": "过期秒数",
"description": "未见到的车牌从追踪器中过期的时间(秒)(仅适用于专用 LPR 摄像头)。"
}
},
"onvif": {
"label": "ONVIF",
"description": "此摄像头的 ONVIF 连接和 PTZ 自动追踪设置。",
"host": {
"label": "ONVIF 主机",
"description": "此摄像头 ONVIF 服务的主机(和可选协议)。"
},
"port": {
"label": "ONVIF 端口",
"description": "ONVIF 服务的端口号。"
},
"user": {
"label": "ONVIF 用户名",
"description": "ONVIF 身份验证的用户名;某些设备需要管理员用户才能使用 ONVIF。"
},
"password": {
"label": "ONVIF 密码",
"description": "ONVIF 身份验证的密码。"
},
"tls_insecure": {
"label": "禁用 TLS 验证",
"description": "跳过 TLS 验证并禁用 ONVIF 的摘要认证(不安全;仅用于安全网络)。"
},
"autotracking": {
"label": "自动追踪",
"description": "使用 PTZ 摄像头移动自动追踪移动目标并使其保持在画面中心。",
"enabled": {
"label": "开启自动追踪",
"description": "启用或禁用检测目标的自动 PTZ 摄像头追踪。"
},
"calibrate_on_startup": {
"label": "启动时校准",
"description": "在启动时测量 PTZ 电机速度以提高追踪精度。Frigate 将在校准后用 movement_weights 更新配置。"
},
"zooming": {
"label": "变焦模式",
"description": "控制变焦行为disabled仅平移/倾斜、absolute最兼容或 relative同时平移/倾斜/变焦)。"
},
"zoom_factor": {
"label": "变焦因子",
"description": "控制追踪目标的变焦级别。数值越低保持更多场景可见;数值越高放大更近但可能丢失追踪。数值范围 0.1 到 0.75。"
},
"track": {
"label": "追踪目标",
"description": "应触发自动追踪的目标类型列表。"
},
"required_zones": {
"label": "必需区域",
"description": "目标必须进入这些区域之一才能开始自动追踪。"
},
"return_preset": {
"label": "返回预设",
"description": "追踪结束后返回的摄像头固件中配置的 ONVIF 预设名称。"
},
"timeout": {
"label": "返回超时",
"description": "失去追踪后等待多少秒后将摄像头返回到预设位置。"
},
"movement_weights": {
"label": "移动权重",
"description": "由摄像头校准自动生成的校准值。请勿手动修改。"
},
"enabled_in_config": {
"label": "原始自动追踪状态",
"description": "用于追踪配置中是否启用自动追踪的内部字段。"
}
},
"ignore_time_mismatch": {
"label": "忽略时间不匹配",
"description": "忽略 ONVIF 通信中摄像头和 Frigate 服务器之间的时间同步差异。"
}
},
"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": "保持摄像头的原始状态跟踪。"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1 +1,73 @@
{}
{
"audio": {
"global": {
"detection": "全局检测",
"sensitivity": "全局灵敏度"
},
"cameras": {
"detection": "检测",
"sensitivity": "灵敏度"
}
},
"timestamp_style": {
"global": {
"appearance": "全局外观"
},
"cameras": {
"appearance": "外观"
}
},
"motion": {
"global": {
"sensitivity": "全局灵敏度",
"algorithm": "全局算法"
},
"cameras": {
"sensitivity": "灵敏度",
"algorithm": "算法"
}
},
"snapshots": {
"global": {
"display": "全局显示"
},
"cameras": {
"display": "显示"
}
},
"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 参数"
}
}
}

View File

@ -0,0 +1,78 @@
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 "./ws";
import { WsProvider } from "./WsProvider";
import axios from "axios";
import { ReactNode } from "react";
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";

View File

@ -1,6 +1,11 @@
import { baseUrl } from "./baseUrl";
import { useCallback, useEffect, useRef, useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from "react";
import {
EmbeddingsReindexProgressType,
FrigateCameraState,
@ -14,8 +19,11 @@ import {
Job,
} from "@/types/ws";
import { FrigateStats } from "@/types/stats";
import { createContainer } from "react-tracked";
import useDeepMemo from "@/hooks/use-deep-memo";
import { isEqual } from "lodash";
import { WsSendContext } from "./wsContext";
import type { Update, WsSend } from "./wsContext";
export type { Update };
export type WsFeedMessage = {
topic: string;
@ -24,170 +32,204 @@ export type WsFeedMessage = {
id: string;
};
type Update = {
topic: string;
payload: unknown;
retain: boolean;
};
type WsState = {
[topic: string]: unknown;
};
type useValueReturn = [WsState, (update: Update) => void];
// External store for WebSocket state using useSyncExternalStore
type Listener = () => 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;
function useValue(): useValueReturn {
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
// 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;
// main state
function applyCameraActivity(payload: string) {
// Fast path: if the raw JSON string is identical, nothing changed.
if (payload === lastCameraActivityPayload) return;
lastCameraActivityPayload = payload;
const [wsState, setWsState] = useState<WsState>({});
let activity: { [key: string]: Partial<FrigateCameraState> };
useEffect(() => {
const activityValue: string = wsState["camera_activity"] as string;
try {
activity = JSON.parse(payload);
} catch {
return;
}
if (!activityValue) {
return;
}
if (Object.keys(activity).length === 0) return;
let cameraActivity: { [key: string]: Partial<FrigateCameraState> };
for (const [name, state] of Object.entries(activity)) {
applyTopicUpdate(`camera_activity/${name}`, state);
try {
cameraActivity = JSON.parse(activityValue);
} catch {
return;
}
const cameraConfig = state?.config;
if (!cameraConfig) continue;
if (Object.keys(cameraActivity).length === 0) {
return;
}
const {
record,
detect,
enabled,
snapshots,
audio,
audio_transcription,
notifications,
notifications_suspended,
autotracking,
alerts,
detections,
object_descriptions,
review_descriptions,
} = cameraConfig;
const cameraStates: WsState = {};
Object.entries(cameraActivity).forEach(([name, state]) => {
const cameraConfig = state?.config;
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];
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",
);
}
}
export const {
Provider: WsProvider,
useTrackedState: useWsState,
useUpdate: useWsUpdate,
} = createContainer(useValue, { defaultState: {}, concurrentMode: true });
// Hooks
export function useWsUpdate(): WsSend {
const send = useContext(WsSendContext);
if (!send) {
throw new Error("useWsUpdate must be used within WsProvider");
}
return send;
}
// 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 state = useWsState();
const payload = useSyncExternalStore(
useCallback(
(listener: Listener) => subscribeWsTopic(watchTopic, listener),
[watchTopic],
),
useCallback(() => wsState[watchTopic], [watchTopic]),
);
const sendJsonMessage = useWsUpdate();
const value = { payload: state[watchTopic] || null };
const value = { payload: payload ?? null };
const send = useCallback(
(payload: unknown, retain = false) => {
@ -203,6 +245,8 @@ 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;
@ -413,28 +457,42 @@ export function useFrigateEvents(): { payload: FrigateEvent } {
const {
value: { payload },
} = useWs("events", "");
return { payload: JSON.parse(payload as string) };
const parsed = useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
return { payload: parsed };
}
export function useAudioDetections(): { payload: FrigateAudioDetections } {
const {
value: { payload },
} = useWs("audio_detections", "");
return { payload: JSON.parse(payload as string) };
const parsed = useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
return { payload: parsed };
}
export function useFrigateReviews(): FrigateReview {
const {
value: { payload },
} = useWs("reviews", "");
return useDeepMemo(JSON.parse(payload as string));
return useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
}
export function useFrigateStats(): FrigateStats {
const {
value: { payload },
} = useWs("stats", "");
return useDeepMemo(JSON.parse(payload as string));
return useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
}
export function useInitialCameraState(
@ -446,32 +504,31 @@ export function useInitialCameraState(
const {
value: { payload },
send: sendCommand,
} = useWs("camera_activity", "onConnect");
} = useWs(`camera_activity/${camera}`, "onConnect");
const data = useDeepMemo(JSON.parse(payload as string));
// camera_activity sub-topic payload is already parsed by expandCameraActivity
const data = payload as FrigateCameraState | undefined;
// 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(() => {
let listener = undefined;
if (revalidateOnFocus) {
sendCommand("onConnect");
listener = () => {
if (document.visibilityState == "visible") {
sendCommand("onConnect");
}
};
addEventListener("visibilitychange", listener);
}
if (!revalidateOnFocus) return;
return () => {
if (listener) {
removeEventListener("visibilitychange", listener);
const listener = () => {
if (document.visibilityState === "visible") {
sendCommand("onConnect");
}
};
// only refresh when onRefresh value changes
addEventListener("visibilitychange", listener);
return () => {
removeEventListener("visibilitychange", listener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revalidateOnFocus]);
return { payload: data ? data[camera] : undefined };
return { payload: data as FrigateCameraState };
}
export function useModelState(
@ -483,7 +540,10 @@ export function useModelState(
send: sendCommand,
} = useWs("model_state", "modelState");
const data = useDeepMemo(JSON.parse(payload as string));
const data = useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
useEffect(() => {
let listener = undefined;
@ -519,7 +579,10 @@ export function useEmbeddingsReindexProgress(
send: sendCommand,
} = useWs("embeddings_reindex_progress", "embeddingsReindexProgress");
const data = useDeepMemo(JSON.parse(payload as string));
const data = useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
useEffect(() => {
let listener = undefined;
@ -553,8 +616,9 @@ export function useAudioTranscriptionProcessState(
send: sendCommand,
} = useWs("audio_transcription_state", "audioTranscriptionState");
const data = useDeepMemo(
payload ? (JSON.parse(payload as string) as string) : "idle",
const data = useMemo(
() => (payload ? (JSON.parse(payload as string) as string) : "idle"),
[payload],
);
useEffect(() => {
@ -587,7 +651,10 @@ export function useBirdseyeLayout(revalidateOnFocus: boolean = true): {
send: sendCommand,
} = useWs("birdseye_layout", "birdseyeLayout");
const data = useDeepMemo(JSON.parse(payload as string));
const data = useMemo(
() => (payload ? JSON.parse(payload as string) : undefined),
[payload],
);
useEffect(() => {
let listener = undefined;
@ -684,10 +751,14 @@ export function useTrackedObjectUpdate(): {
const {
value: { payload },
} = useWs("tracked_object_update", "");
const parsed = payload
? JSON.parse(payload as string)
: { type: "", id: "", camera: "" };
return { payload: useDeepMemo(parsed) };
const parsed = useMemo(
() =>
payload
? JSON.parse(payload as string)
: { type: "", id: "", camera: "" },
[payload],
);
return { payload: parsed };
}
export function useNotifications(camera: string): {
@ -730,10 +801,14 @@ export function useTriggers(): { payload: TriggerStatus } {
const {
value: { payload },
} = useWs("triggers", "");
const parsed = payload
? JSON.parse(payload as string)
: { name: "", camera: "", event_id: "", type: "", score: 0 };
return { payload: useDeepMemo(parsed) };
const parsed = useMemo(
() =>
payload
? JSON.parse(payload as string)
: { name: "", camera: "", event_id: "", type: "", score: 0 },
[payload],
);
return { payload: parsed };
}
export function useJobStatus(
@ -745,8 +820,9 @@ export function useJobStatus(
send: sendCommand,
} = useWs("job_state", "jobState");
const jobData = useDeepMemo(
payload && typeof payload === "string" ? JSON.parse(payload) : {},
const jobData = useMemo(
() => (payload && typeof payload === "string" ? JSON.parse(payload) : {}),
[payload],
);
const currentJob = jobData[jobType] || null;

11
web/src/api/wsContext.ts Normal file
View File

@ -0,0 +1,11 @@
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 currentTs = currentTime.getTime() / 1000;
if (currentTs - time < 60) {
const elapsedMs = currentTime.getTime() - time;
if (elapsedMs < 60000) {
return 1000; // refresh every second
} else if (currentTs - time < 3600) {
} else if (elapsedMs < 3600000) {
return 60000; // refresh every minute
} else {
return 3600000; // refresh every hour

View File

@ -1,18 +1,70 @@
import { useMemo } from "react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { Polygon } from "@/types/canvas";
import { useWsState } from "@/api/ws";
import { subscribeWsTopic, getWsTopicValue } from "@/api/ws";
/**
* Hook to get enabled state for a polygon from websocket state.
* Memoizes the lookup function to avoid unnecessary re-renders.
* Subscribes to all relevant per-polygon topics so it only re-renders
* when one of those specific topics changes not on every WS update.
*/
export function usePolygonStates(polygons: Polygon[]) {
const wsState = useWsState();
// 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]);
// Create a memoized lookup map that only updates when relevant ws values change
// 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
return useMemo(() => {
const stateMap = new Map<string, boolean>();
// 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"
@ -21,7 +73,7 @@ export function usePolygonStates(polygons: Polygon[]) {
? `${polygon.camera}/motion_mask/${polygon.name}/state`
: `${polygon.camera}/object_mask/${polygon.name}/state`;
const wsValue = wsState[topic];
const wsValue = valueMap.get(topic);
const enabled =
wsValue === "ON"
? true
@ -40,5 +92,5 @@ export function usePolygonStates(polygons: Polygon[]) {
true
);
};
}, [polygons, wsState]);
}, [polygons, snapshot]);
}