Merge branch 'dev' of https://github.com/blakeblackshear/frigate into addon_config

This commit is contained in:
Felipe Santos 2025-03-21 19:42:49 -03:00
commit ebba713191
211 changed files with 10886 additions and 2851 deletions

View File

@ -2,12 +2,12 @@
<!-- <!--
Thank you! Thank you!
If you're introducing a new feature or significantly refactoring existing functionality, If you're introducing a new feature or significantly refactoring existing functionality,
we encourage you to start a discussion first. This helps ensure your idea aligns with we encourage you to start a discussion first. This helps ensure your idea aligns with
Frigate's development goals. Frigate's development goals.
Describe what this pull request does and how it will benefit users of Frigate. Describe what this pull request does and how it will benefit users of Frigate.
Please describe in detail any considerations, breaking changes, etc. that are Please describe in detail any considerations, breaking changes, etc. that are
made in this pull request. made in this pull request.
--> -->
@ -24,7 +24,7 @@
## Additional information ## Additional information
- This PR fixes or closes issue: fixes # - This PR fixes or closes issue: fixes #
- This PR is related to issue: - This PR is related to issue:
## Checklist ## Checklist
@ -35,4 +35,5 @@
- [ ] The code change is tested and works locally. - [ ] The code change is tested and works locally.
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass** - [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
- [ ] There is no commented out code in this PR. - [ ] There is no commented out code in this PR.
- [ ] UI changes including text have used i18n keys and have been added to the `en` locale.
- [ ] The code has been formatted using Ruff (`ruff format frigate`) - [ ] The code has been formatted using Ruff (`ruff format frigate`)

View File

@ -4,6 +4,8 @@
# Frigate - NVR With Realtime Object Detection for IP Cameras # Frigate - NVR With Realtime Object Detection for IP Cameras
\[English\] | [简体中文](https://github.com/blakeblackshear/frigate/README_CN.md)
A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras. A complete and local NVR designed for [Home Assistant](https://www.home-assistant.io) with AI object detection. Uses OpenCV and Tensorflow to perform realtime object detection locally for IP cameras.
Use of a [Google Coral Accelerator](https://coral.ai/products/) is optional, but highly recommended. The Coral will outperform even the best CPUs and can process 100+ FPS with very little overhead. Use of a [Google Coral Accelerator](https://coral.ai/products/) is optional, but highly recommended. The Coral will outperform even the best CPUs and can process 100+ FPS with very little overhead.

52
README_CN.md Normal file
View File

@ -0,0 +1,52 @@
<p align="center">
<img align="center" alt="logo" src="docs/static/img/frigate.png">
</p>
# Frigate - 一个具有实时目标检测的本地NVR
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
一个完整的本地网络视频录像机NVR专为[Home Assistant](https://www.home-assistant.io)设计具备AI物体检测功能。使用OpenCV和TensorFlow在本地为IP摄像头执行实时物体检测。
强烈推荐使用可选配件:[Google Coral加速器](https://coral.ai/products/)。在该场景下Coral的性能甚至超过目前的顶级CPU并且可以以极低的电力开销轻松处理100 以上的画面帧。
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与Home Assistant紧密集成
- 设计上通过仅在必要时和必要地点寻找物体,最大限度地减少资源使用并最大化性能
- 大量利用多进程处理,强调实时性而非处理每一帧
- 使用非常低开销的运动检测来确定运行物体检测的位置
- 使用TensorFlow进行物体检测运行在单独的进程中以达到最大FPS
- 通过MQTT进行通信便于集成到其他系统中
- 根据检测到的物体设置保留时间进行视频录制
- 24/7全天候录制
- 通过RTSP重新流传输以减少摄像头的连接数
- 支持WebRTC和MSE实现低延迟的实时观看
## 文档(英文)
你可以在这里查看文档 https://docs.frigate.video
## 赞助
如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。
## 截图
### 实时监控面板
<div>
<img width="800" alt="实时监控面板" src="https://github.com/blakeblackshear/frigate/assets/569905/5e713cb9-9db5-41dc-947a-6937c3bc376e">
</div>
### 简单的审查工作流程
<div>
<img width="800" alt="简单的审查工作流程" src="https://github.com/blakeblackshear/frigate/assets/569905/6fed96e8-3b18-40e5-9ddc-31e6f3c9f2ff">
</div>
### 多摄像头可按时间轴查看
<div>
<img width="800" alt="多摄像头可按时间轴查看" src="https://github.com/blakeblackshear/frigate/assets/569905/d6788a15-0eeb-4427-a8d4-80b93cae3d74">
</div>
### 内置遮罩和区域编辑器
<div>
<img width="800" alt="内置遮罩和区域编辑器" src="https://github.com/blakeblackshear/frigate/assets/569905/d7885fc3-bfe6-452f-b7d0-d957cb3e31f5">
</div>

View File

@ -79,6 +79,11 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null -keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null
fi fi
# build templates for optional FRIGATE_BASE_PATH environment variable
python3 /usr/local/nginx/get_base_path.py | \
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
-out /usr/local/nginx/conf/base_path.conf
# build templates for optional TLS support # build templates for optional TLS support
python3 /usr/local/nginx/get_tls_settings.py | \ python3 /usr/local/nginx/get_tls_settings.py | \
tempio -template /usr/local/nginx/templates/listen.gotmpl \ tempio -template /usr/local/nginx/templates/listen.gotmpl \

View File

@ -96,6 +96,7 @@ http {
gzip_types application/vnd.apple.mpegurl; gzip_types application/vnd.apple.mpegurl;
include auth_location.conf; include auth_location.conf;
include base_path.conf;
location /vod/ { location /vod/ {
include auth_request.conf; include auth_request.conf;
@ -299,11 +300,29 @@ http {
add_header Cache-Control "public"; add_header Cache-Control "public";
} }
location /locales/ {
access_log off;
add_header Cache-Control "public";
}
location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ {
access_log off;
expires 1y;
add_header Cache-Control "public";
default_type application/json;
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types application/json;
sub_filter '"start_url": "/"' '"start_url" : "$http_x_ingress_path"';
sub_filter '"src": "/' '"src": "$http_x_ingress_path/';
}
sub_filter 'href="/BASE_PATH/' 'href="$http_x_ingress_path/'; sub_filter 'href="/BASE_PATH/' 'href="$http_x_ingress_path/';
sub_filter 'url(/BASE_PATH/' 'url($http_x_ingress_path/'; sub_filter 'url(/BASE_PATH/' 'url($http_x_ingress_path/';
sub_filter '"/BASE_PATH/dist/' '"$http_x_ingress_path/dist/'; sub_filter '"/BASE_PATH/dist/' '"$http_x_ingress_path/dist/';
sub_filter '"/BASE_PATH/js/' '"$http_x_ingress_path/js/'; sub_filter '"/BASE_PATH/js/' '"$http_x_ingress_path/js/';
sub_filter '"/BASE_PATH/assets/' '"$http_x_ingress_path/assets/'; sub_filter '"/BASE_PATH/assets/' '"$http_x_ingress_path/assets/';
sub_filter '"/BASE_PATH/locales/' '"$http_x_ingress_path/locales/';
sub_filter '"/BASE_PATH/monacoeditorwork/' '"$http_x_ingress_path/assets/'; sub_filter '"/BASE_PATH/monacoeditorwork/' '"$http_x_ingress_path/assets/';
sub_filter 'return"/BASE_PATH/"' 'return window.baseUrl'; sub_filter 'return"/BASE_PATH/"' 'return window.baseUrl';
sub_filter '<body>' '<body><script>window.baseUrl="$http_x_ingress_path/";</script>'; sub_filter '<body>' '<body><script>window.baseUrl="$http_x_ingress_path/";</script>';

View File

@ -0,0 +1,10 @@
"""Prints the base path as json to stdout."""
import json
import os
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
result: dict[str, any] = {"base_path": base_path}
print(json.dumps(result))

View File

@ -0,0 +1,19 @@
{{ if .base_path }}
location = {{ .base_path }} {
return 302 {{ .base_path }}/;
}
location ^~ {{ .base_path }}/ {
# remove base_url from the path before passing upstream
rewrite ^{{ .base_path }}/(.*) /$1 break;
proxy_pass $scheme://127.0.0.1:8971;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Ingress-Path {{ .base_path }};
access_log off;
}
{{ end }}

View File

@ -172,6 +172,38 @@ listen [::]:8971 ipv6only=off ssl;
listen [::]:5000 ipv6only=off; listen [::]:5000 ipv6only=off;
``` ```
## Base path
By default, Frigate runs at the root path (`/`). However some setups require to run Frigate under a custom path prefix (e.g. `/frigate`), especially when Frigate is located behind a reverse proxy that requires path-based routing.
### Set Base Path via HTTP Header
The preferred way to configure the base path is through the `X-Ingress-Path` HTTP header, which needs to be set to the desired base path in an upstream reverse proxy.
For example, in Nginx:
```
location /frigate {
proxy_set_header X-Ingress-Path /frigate;
proxy_pass http://frigate_backend;
}
```
### Set Base Path via Environment Variable
When it is not feasible to set the base path via a HTTP header, it can also be set via the `FRIGATE_BASE_PATH` environment variable in the Docker Compose file.
For example:
```
services:
frigate:
image: blakeblackshear/frigate:latest
environment:
- FRIGATE_BASE_PATH=/frigate
```
This can be used for example to access Frigate via a Tailscale agent (https), by simply forwarding all requests to the base path (http):
```
tailscale serve --https=443 --bg --set-path /frigate http://localhost:5000/frigate
```
## Custom Dependencies ## Custom Dependencies
### Custom ffmpeg build ### Custom ffmpeg build

View File

@ -3,19 +3,55 @@ id: face_recognition
title: Face Recognition title: Face Recognition
--- ---
Face recognition allows people to be assigned names and when their face is recognized Frigate will assign the person's name as a sub label. This information is included in the UI, filters, as well as in notifications. Face recognition identifies known individuals by matching detected faces with previously learned facial data. When a known person is recognized, their name will be added as a `sub_label`. This information is included in the UI, filters, as well as in notifications.
## Model Requirements
Frigate has support for CV2 Local Binary Pattern Face Recognizer to recognize faces, which runs locally. A lightweight face landmark detection model is also used to align faces before running them through the face recognizer. Frigate has support for CV2 Local Binary Pattern Face Recognizer to recognize faces, which runs locally. A lightweight face landmark detection model is also used to align faces before running them through the face recognizer.
Users running a Frigate+ model (or any custom model that natively detects faces) should ensure that `face` is added to the [list of objects to track](../plus/#available-label-types) either globally or for a specific camera. This will allow face detection to run at the same time as object detection and be more efficient.
Users without a model that detects faces can still run face recognition. Frigate uses a lightweight DNN face detection model that runs on the CPU. In this case, you should _not_ define `face` in your list of objects to track.
:::note
Frigate needs to first detect a `face` before it can recognize a face.
:::
## Minimum System Requirements
Face recognition is lightweight and runs on the CPU, there are no significantly different system requirements than running Frigate itself.
## Configuration ## Configuration
Face recognition is disabled by default, face recognition must be enabled in your config file before it can be used. Face recognition is a global configuration setting. Face recognition is disabled by default, face recognition must be enabled in the UI or in your config file before it can be used. Face recognition is a global configuration setting.
```yaml ```yaml
face_recognition: face_recognition:
enabled: true enabled: true
``` ```
## Advanced Configuration
Fine-tune face recognition with these optional parameters:
### Detection
- `detection_threshold`: Face detection confidence score required before recognition runs:
- Default: `0.7`
- Note: This is field only applies to the standalone face detection model, `min_score` should be used to filter for models that have face detection built in.
- `min_area`: Defines the minimum size (in pixels) a face must be before recognition runs.
- Default: `500` pixels.
- Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant faces.
### Recognition
- `recognition_threshold`: Recognition confidence score required to add the face to the object as a sub label.
- Default: `0.9`.
- `blur_confidence_filter`: Enables a filter that calculates how blurry the face is and adjusts the confidence based on this.
- Default: `True`.
## Dataset ## Dataset
The number of images needed for a sufficient training set for face recognition varies depending on several factors: The number of images needed for a sufficient training set for face recognition varies depending on several factors:

View File

@ -51,16 +51,16 @@ Fine-tune the LPR feature using these optional parameters:
- **`detection_threshold`**: License plate object detection confidence score required before recognition runs. - **`detection_threshold`**: License plate object detection confidence score required before recognition runs.
- Default: `0.7` - Default: `0.7`
- Note: If you are using a Frigate+ model and you set the `threshold` in your objects config for `license_plate` higher than this value, recognition will never run. It's best to ensure these values match, or this `detection_threshold` is lower than your object config `threshold`. - Note: This is field only applies to the standalone license plate detection model, `min_score` should be used to filter for models that have license plate detection built in.
- **`min_area`**: Defines the minimum size (in pixels) a license plate must be before recognition runs. - **`min_area`**: Defines the minimum size (in pixels) a license plate must be before recognition runs.
- Default: `1000` pixels. - Default: `1000` pixels.
- Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates. - Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates.
### Recognition ### Recognition
- **`recognition_threshold`**: Recognition confidence score required to add the plate to the object as a sub label. - **`recognition_threshold`**: Recognition confidence score required to add the plate to the object as a `recognized_license_plate` and/or `sub_label`.
- Default: `0.9`. - Default: `0.9`.
- **`min_plate_length`**: Specifies the minimum number of characters a detected license plate must have to be added as a sub label to an object. - **`min_plate_length`**: Specifies the minimum number of characters a detected license plate must have to be added as a `recognized_license_plate` and/or `sub_label` to an object.
- Use this to filter out short, incomplete, or incorrect detections. - Use this to filter out short, incomplete, or incorrect detections.
- **`format`**: A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded. - **`format`**: A regular expression defining the expected format of detected plates. Plates that do not match this format will be discarded.
- `"^[A-Z]{1,3} [A-Z]{1,2} [0-9]{1,4}$"` matches plates like "B AB 1234" or "M X 7" - `"^[A-Z]{1,3} [A-Z]{1,2} [0-9]{1,4}$"` matches plates like "B AB 1234" or "M X 7"

View File

@ -129,8 +129,8 @@ detectors:
type: edgetpu type: edgetpu
device: pci device: pci
``` ```
---
---
## Hailo-8 ## Hailo-8
@ -140,12 +140,13 @@ See the [installation docs](../frigate/installation.md#hailo-8l) for information
### Configuration ### Configuration
When configuring the Hailo detector, you have two options to specify the model: a local **path** or a **URL**. When configuring the Hailo detector, you have two options to specify the model: a local **path** or a **URL**.
If both are provided, the detector will first check for the model at the given local path. If the file is not found, it will download the model from the specified URL. The model file is cached under `/config/model_cache/hailo`. If both are provided, the detector will first check for the model at the given local path. If the file is not found, it will download the model from the specified URL. The model file is cached under `/config/model_cache/hailo`.
#### YOLO #### YOLO
Use this configuration for YOLO-based models. When no custom model path or URL is provided, the detector automatically downloads the default model based on the detected hardware: Use this configuration for YOLO-based models. When no custom model path or URL is provided, the detector automatically downloads the default model based on the detected hardware:
- **Hailo-8 hardware:** Uses **YOLOv6n** (default: `yolov6n.hef`) - **Hailo-8 hardware:** Uses **YOLOv6n** (default: `yolov6n.hef`)
- **Hailo-8L hardware:** Uses **YOLOv6n** (default: `yolov6n.hef`) - **Hailo-8L hardware:** Uses **YOLOv6n** (default: `yolov6n.hef`)
@ -224,17 +225,16 @@ model:
# Alternatively, or as a fallback, provide a custom URL: # Alternatively, or as a fallback, provide a custom URL:
# path: https://custom-model-url.com/path/to/model.hef # path: https://custom-model-url.com/path/to/model.hef
``` ```
For additional ready-to-use models, please visit: https://github.com/hailo-ai/hailo_model_zoo For additional ready-to-use models, please visit: https://github.com/hailo-ai/hailo_model_zoo
Hailo8 supports all models in the Hailo Model Zoo that include HailoRT post-processing. You're welcome to choose any of these pre-configured models for your implementation. Hailo8 supports all models in the Hailo Model Zoo that include HailoRT post-processing. You're welcome to choose any of these pre-configured models for your implementation.
> **Note:** > **Note:**
> The config.path parameter can accept either a local file path or a URL ending with .hef. When provided, the detector will first check if the path is a local file path. If the file exists locally, it will use it directly. If the file is not found locally or if a URL was provided, it will attempt to download the model from the specified URL. > The config.path parameter can accept either a local file path or a URL ending with .hef. When provided, the detector will first check if the path is a local file path. If the file exists locally, it will use it directly. If the file is not found locally or if a URL was provided, it will attempt to download the model from the specified URL.
--- ---
## OpenVINO Detector ## OpenVINO Detector
The OpenVINO detector type runs an OpenVINO IR model on AMD and Intel CPUs, Intel GPUs and Intel VPU hardware. To configure an OpenVINO detector, set the `"type"` attribute to `"openvino"`. The OpenVINO detector type runs an OpenVINO IR model on AMD and Intel CPUs, Intel GPUs and Intel VPU hardware. To configure an OpenVINO detector, set the `"type"` attribute to `"openvino"`.
@ -340,6 +340,30 @@ model:
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects. Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
#### D-FINE
[D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
After placing the downloaded onnx model in your config/model_cache folder, you can use the following configuration:
```yaml
detectors:
ov:
type: openvino
device: GPU
model:
model_type: dfine
width: 640
height: 640
input_tensor: nchw
input_dtype: float
path: /config/model_cache/dfine_s_obj2coco.onnx
labelmap_path: /labelmap/coco-80.txt
```
Note that the labelmap uses a subset of the complete COCO label set that has only 80 objects.
## NVidia TensorRT Detector ## NVidia TensorRT Detector
Nvidia GPUs may be used for object detection using the TensorRT libraries. Due to the size of the additional libraries, this detector is only provided in images with the `-tensorrt` tag suffix, e.g. `ghcr.io/blakeblackshear/frigate:stable-tensorrt`. This detector is designed to work with Yolo models for object detection. Nvidia GPUs may be used for object detection using the TensorRT libraries. Due to the size of the additional libraries, this detector is only provided in images with the `-tensorrt` tag suffix, e.g. `ghcr.io/blakeblackshear/frigate:stable-tensorrt`. This detector is designed to work with Yolo models for object detection.
@ -529,6 +553,7 @@ $ docker exec -it frigate /bin/bash -c '(unset HSA_OVERRIDE_GFX_VERSION && /opt/
### Supported Models ### Supported Models
See [ONNX supported models](#supported-models) for supported models, there are some caveats: See [ONNX supported models](#supported-models) for supported models, there are some caveats:
- D-FINE models are not supported - D-FINE models are not supported
- YOLO-NAS models are known to not run well on integrated GPUs - YOLO-NAS models are known to not run well on integrated GPUs
@ -626,12 +651,6 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
[D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate. [D-FINE](https://github.com/Peterande/D-FINE) is the [current state of the art](https://paperswithcode.com/sota/real-time-object-detection-on-coco?p=d-fine-redefine-regression-task-in-detrs-as) at the time of writing. The ONNX exported models are supported, but not included by default. See [the models section](#downloading-d-fine-model) for more information on downloading the D-FINE model for use in Frigate.
:::warning
D-FINE is currently not supported on OpenVINO
:::
After placing the downloaded onnx model in your config/model_cache folder, you can use the following configuration: After placing the downloaded onnx model in your config/model_cache folder, you can use the following configuration:
```yaml ```yaml

View File

@ -543,12 +543,23 @@ semantic_search:
model_size: "small" model_size: "small"
# Optional: Configuration for face recognition capability # Optional: Configuration for face recognition capability
# NOTE: Can (enabled, min_area) be overridden at the camera level
face_recognition: face_recognition:
# Optional: Enable semantic search (default: shown below) # Optional: Enable semantic search (default: shown below)
enabled: False enabled: False
# Optional: Set the model size used for embeddings. (default: shown below) # Optional: Minimum face distance score required to save the attempt (default: shown below)
# NOTE: small model runs on CPU and large model runs on GPU min_score: 0.8
model_size: "small" # Optional: Minimum face detection score required to detect a face (default: shown below)
# NOTE: This only applies when not running a Frigate+ model
detection_threshold: 0.7
# Optional: Minimum face distance score required to be considered a match (default: shown below)
recognition_threshold: 0.9
# Optional: Min area of detected face box to consider running face recognition (default: shown below)
min_area: 500
# Optional: Save images of recognized faces for training (default: shown below)
save_attempts: True
# Optional: Apply a blur quality filter to adjust confidence based on the blur level of the image (default: shown below)
blur_confidence_filter: True
# Optional: Configuration for license plate recognition capability # Optional: Configuration for license plate recognition capability
lpr: lpr:

View File

@ -72,17 +72,17 @@ COPY --from=rootfs / /
The images for each board will be built for each Frigate release, this is done in the `.github/workflows/ci.yml` file. The board build workflow will need to be added here. The images for each board will be built for each Frigate release, this is done in the `.github/workflows/ci.yml` file. The board build workflow will need to be added here.
```yml ```yml
- name: Build and push board build - name: Build and push board build
uses: docker/bake-action@v3 uses: docker/bake-action@v3
with: with:
push: true push: true
targets: board # this is the target in the board.hcl file targets: board # this is the target in the board.hcl file
files: docker/board/board.hcl # this should be updated with the actual board type files: docker/board/board.hcl # this should be updated with the actual board type
# the tags should be updated with the actual board types as well # the tags should be updated with the actual board types as well
# the community board builds should never push to cache, but it can pull from cache # the community board builds should never push to cache, but it can pull from cache
set: | set: |
board.tags=ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ env.SHORT_SHA }}-board board.tags=ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ env.SHORT_SHA }}-board
*.cache-from=type=gha *.cache-from=type=gha
``` ```
### Code Owner File ### Code Owner File

View File

@ -235,3 +235,14 @@ When testing nginx config changes from within the dev container, the following c
```console ```console
sudo cp docker/main/rootfs/usr/local/nginx/conf/* /usr/local/nginx/conf/ && sudo /usr/local/nginx/sbin/nginx -s reload sudo cp docker/main/rootfs/usr/local/nginx/conf/* /usr/local/nginx/conf/ && sudo /usr/local/nginx/sbin/nginx -s reload
``` ```
## Contributing translations of the Web UI
If you'd like to contribute translations to Frigate, please follow these steps:
1. Fork the repository and create a new branch specifically for your translation work
2. Locate the localization files in the web/public/locales directory
3. Add or modify the appropriate language JSON files, maintaining the existing key structure while translating only the values
4. Ensure your translations maintain proper formatting, including any placeholder variables (like `{{example}}`)
5. Before submitting, thoroughly review the UI
6. When creating your PR, include a brief description of the languages you've added or updated, and reference any related issues

View File

@ -305,6 +305,10 @@ Topic to adjust motion contour area for a camera. Expected value is an integer.
Topic with current motion contour area for a camera. Published value is an integer. Topic with current motion contour area for a camera. Published value is an integer.
### `frigate/<camera_name>/review_status`
Topic with current activity status of the camera. Possible values are `NONE`, `DETECTION`, or `ALERT`.
### `frigate/<camera_name>/ptz` ### `frigate/<camera_name>/ptz`
Topic to send PTZ commands to camera. Topic to send PTZ commands to camera.

View File

@ -22,3 +22,13 @@ Yes. Models and metadata are stored in the `model_cache` directory within the co
### Can I keep using my Frigate+ models even if I do not renew my subscription? ### Can I keep using my Frigate+ models even if I do not renew my subscription?
Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models. Yes. Subscriptions to Frigate+ provide access to the infrastructure used to train the models. Models trained with your subscription are yours to keep and use forever. However, do note that the terms and conditions prohibit you from sharing, reselling, or creating derivative products from the models.
### Why can't I submit images to Frigate+?
If you've configured your API key and the Frigate+ Settings page in the UI shows that the key is active, you need to ensure that you've enabled both snapshots and `clean_copy` snapshots for the cameras you'd like to submit images for. Note that `clean_copy` is enabled by default when snapshots are enabled.
```yaml
snapshots:
enabled: true
clean_copy: true
```

View File

@ -9,6 +9,7 @@ import traceback
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import reduce from functools import reduce
from io import StringIO from io import StringIO
from pathlib import Path as FilePath
from typing import Any, Optional from typing import Any, Optional
import aiofiles import aiofiles
@ -79,12 +80,16 @@ def go2rtc_streams():
@router.get("/go2rtc/streams/{camera_name}") @router.get("/go2rtc/streams/{camera_name}")
def go2rtc_camera_stream(camera_name: str): def go2rtc_camera_stream(request: Request, camera_name: str):
r = requests.get( r = requests.get(
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=all&microphone" f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=all&microphone"
) )
if not r.ok: if not r.ok:
logger.error("Failed to fetch streams from go2rtc") camera_config = request.app.frigate_config.cameras.get(camera_name)
if camera_config and camera_config.enabled:
logger.error("Failed to fetch streams from go2rtc")
return JSONResponse( return JSONResponse(
content=({"success": False, "message": "Error fetching stream data"}), content=({"success": False, "message": "Error fetching stream data"}),
status_code=500, status_code=500,
@ -174,6 +179,22 @@ def config(request: Request):
config["model"]["all_attributes"] = config_obj.model.all_attributes config["model"]["all_attributes"] = config_obj.model.all_attributes
config["model"]["non_logo_attributes"] = config_obj.model.non_logo_attributes config["model"]["non_logo_attributes"] = config_obj.model.non_logo_attributes
# Add model plus data if plus is enabled
if config["plus"]["enabled"]:
model_path = config.get("model", {}).get("path")
if model_path:
model_json_path = FilePath(model_path).with_suffix(".json")
try:
with open(model_json_path, "r") as f:
model_plus_data = json.load(f)
config["model"]["plus"] = model_plus_data
except FileNotFoundError:
config["model"]["plus"] = None
except json.JSONDecodeError:
config["model"]["plus"] = None
else:
config["model"]["plus"] = None
# use merged labelamp # use merged labelamp
for detector_config in config["detectors"].values(): for detector_config in config["detectors"].values():
detector_config["model"]["labelmap"] = ( detector_config["model"]["labelmap"] = (

View File

@ -189,21 +189,15 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, sec
async def get_current_user(request: Request): async def get_current_user(request: Request):
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name username = request.headers.get("remote-user")
encoded_token = request.cookies.get(JWT_COOKIE_NAME) role = request.headers.get("remote-role")
if not encoded_token:
return JSONResponse(content={"message": "No JWT token found"}, status_code=401)
try: if not username or not role:
token = jwt.decode(encoded_token, request.app.jwt_token) return JSONResponse(
if "sub" not in token.claims or "role" not in token.claims: content={"message": "No authorization headers."}, status_code=401
return JSONResponse( )
content={"message": "Invalid JWT token"}, status_code=401
) return {"username": username, "role": role}
return {"username": token.claims["sub"], "role": token.claims["role"]}
except Exception as e:
logger.error(f"Error parsing JWT: {e}")
return JSONResponse(content={"message": "Invalid JWT token"}, status_code=401)
def require_role(required_roles: List[str]): def require_role(required_roles: List[str]):
@ -259,12 +253,12 @@ def auth(request: Request):
# pass the user header value from the upstream proxy if a mapping is specified # pass the user header value from the upstream proxy if a mapping is specified
# or use anonymous if none are specified # or use anonymous if none are specified
user_header = proxy_config.header_map.user user_header = proxy_config.header_map.user
role_header = proxy_config.header_map.role
success_response.headers["remote-user"] = ( success_response.headers["remote-user"] = (
request.headers.get(user_header, default="anonymous") request.headers.get(user_header, default="anonymous")
if user_header if user_header
else "anonymous" else "anonymous"
) )
role_header = proxy_config.header_map.role role_header = proxy_config.header_map.role
role = ( role = (
request.headers.get(role_header, default="viewer") request.headers.get(role_header, default="viewer")

View File

@ -6,6 +6,7 @@ import random
import shutil import shutil
import string import string
import cv2
from fastapi import APIRouter, Depends, Request, UploadFile from fastapi import APIRouter, Depends, Request, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
@ -14,9 +15,11 @@ from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config.camera import DetectConfig
from frigate.const import FACE_DIR from frigate.const import FACE_DIR
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Event from frigate.models import Event
from frigate.util.path import get_event_snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,6 +30,9 @@ router = APIRouter(tags=[Tags.events])
def get_faces(): def get_faces():
face_dict: dict[str, list[str]] = {} face_dict: dict[str, list[str]] = {}
if not os.path.exists(FACE_DIR):
return JSONResponse(status_code=200, content={})
for name in os.listdir(FACE_DIR): for name in os.listdir(FACE_DIR):
face_dir = os.path.join(FACE_DIR, name) face_dir = os.path.join(FACE_DIR, name)
@ -36,7 +42,10 @@ def get_faces():
face_dict[name] = [] face_dict[name] = []
for file in sorted( for file in sorted(
os.listdir(face_dir), filter(
lambda f: (f.lower().endswith((".webp", ".png", ".jpg", ".jpeg"))),
os.listdir(face_dir),
),
key=lambda f: os.path.getctime(os.path.join(face_dir, f)), key=lambda f: os.path.getctime(os.path.join(face_dir, f)),
reverse=True, reverse=True,
): ):
@ -87,16 +96,27 @@ def train_face(request: Request, name: str, body: dict = None):
) )
json: dict[str, any] = body or {} json: dict[str, any] = body or {}
training_file = os.path.join( training_file_name = sanitize_filename(json.get("training_file", ""))
FACE_DIR, f"train/{sanitize_filename(json.get('training_file', ''))}" training_file = os.path.join(FACE_DIR, f"train/{training_file_name}")
) event_id = json.get("event_id")
if not training_file or not os.path.isfile(training_file): if not training_file_name and not event_id:
return JSONResponse( return JSONResponse(
content=( content=(
{ {
"success": False, "success": False,
"message": f"Invalid filename or no file exists: {training_file}", "message": "A training file or event_id must be passed.",
}
),
status_code=400,
)
if training_file_name and not os.path.isfile(training_file):
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid filename or no file exists: {training_file_name}",
} }
), ),
status_code=404, status_code=404,
@ -106,7 +126,36 @@ def train_face(request: Request, name: str, body: dict = None):
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
new_name = f"{sanitized_name}-{rand_id}.webp" new_name = f"{sanitized_name}-{rand_id}.webp"
new_file = os.path.join(FACE_DIR, f"{sanitized_name}/{new_name}") new_file = os.path.join(FACE_DIR, f"{sanitized_name}/{new_name}")
shutil.move(training_file, new_file)
if training_file_name:
shutil.move(training_file, new_file)
else:
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
return JSONResponse(
content=(
{
"success": False,
"message": f"Invalid event_id or no event exists: {event_id}",
}
),
status_code=404,
)
snapshot = get_event_snapshot(event)
face_box = event.data["attributes"][0]["box"]
detect_config: DetectConfig = request.app.frigate_config.cameras[
event.camera
].detect
# crop onto the face box minus the bounding box itself
x1 = int(face_box[0] * detect_config.width) + 2
y1 = int(face_box[1] * detect_config.height) + 2
x2 = x1 + int(face_box[2] * detect_config.width) - 4
y2 = y1 + int(face_box[3] * detect_config.height) - 4
face = snapshot[y1:y2, x1:x2]
cv2.imwrite(new_file, face)
context: EmbeddingsContext = request.app.embeddings context: EmbeddingsContext = request.app.embeddings
context.clear_face_classifier() context.clear_face_classifier()
@ -115,7 +164,7 @@ def train_face(request: Request, name: str, body: dict = None):
content=( content=(
{ {
"success": True, "success": True,
"message": f"Successfully saved {training_file} as {new_name}.", "message": f"Successfully saved {training_file_name} as {new_name}.",
} }
), ),
status_code=200, status_code=200,
@ -155,6 +204,22 @@ async def register_face(request: Request, name: str, file: UploadFile):
) )
@router.post("/faces/recognize")
async def recognize_face(request: Request, file: UploadFile):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
status_code=400,
content={"message": "Face recognition is not enabled.", "success": False},
)
context: EmbeddingsContext = request.app.embeddings
result = context.recognize_face(await file.read())
return JSONResponse(
status_code=200 if result.get("success", True) else 400,
content=result,
)
@router.post("/faces/{name}/delete", dependencies=[Depends(require_role(["admin"]))]) @router.post("/faces/{name}/delete", dependencies=[Depends(require_role(["admin"]))])
def deregister_faces(request: Request, name: str, body: dict = None): def deregister_faces(request: Request, name: str, body: dict = None):
if not request.app.frigate_config.face_recognition.enabled: if not request.app.frigate_config.face_recognition.enabled:

View File

@ -701,6 +701,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
for k, v in event["data"].items() for k, v in event["data"].items()
if k if k
in [ in [
"attributes",
"type", "type",
"score", "score",
"top_score", "top_score",

View File

@ -27,6 +27,7 @@ from frigate.api.defs.query.media_query_parameters import (
MediaRecordingsSummaryQueryParams, MediaRecordingsSummaryQueryParams,
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.camera.state import CameraState
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.const import ( from frigate.const import (
CACHE_DIR, CACHE_DIR,
@ -106,10 +107,10 @@ def imagestream(
@router.get("/{camera_name}/ptz/info") @router.get("/{camera_name}/ptz/info")
def camera_ptz_info(request: Request, camera_name: str): async def camera_ptz_info(request: Request, camera_name: str):
if camera_name in request.app.frigate_config.cameras: if camera_name in request.app.frigate_config.cameras:
return JSONResponse( return JSONResponse(
content=request.app.onvif.get_camera_info(camera_name), content=await request.app.onvif.get_camera_info(camera_name),
) )
else: else:
return JSONResponse( return JSONResponse(
@ -765,12 +766,15 @@ def event_snapshot(
except DoesNotExist: except DoesNotExist:
# see if the object is currently being tracked # see if the object is currently being tracked
try: try:
camera_states = request.app.detected_frames_processor.camera_states.values() camera_states: list[CameraState] = (
request.app.detected_frames_processor.camera_states.values()
)
for camera_state in camera_states: for camera_state in camera_states:
if event_id in camera_state.tracked_objects: if event_id in camera_state.tracked_objects:
tracked_obj = camera_state.tracked_objects.get(event_id) tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is not None: if tracked_obj is not None:
jpg_bytes = tracked_obj.get_jpg_bytes( jpg_bytes = tracked_obj.get_img_bytes(
ext="jpg",
timestamp=params.timestamp, timestamp=params.timestamp,
bounding_box=params.bbox, bounding_box=params.bbox,
crop=params.crop, crop=params.crop,
@ -779,17 +783,19 @@ def event_snapshot(
) )
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Event not found"}, content={"success": False, "message": "Ongoing event not found"},
status_code=404, status_code=404,
) )
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Event not found"}, status_code=404 content={"success": False, "message": "Unknown error occurred"},
status_code=404,
) )
if jpg_bytes is None: if jpg_bytes is None:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Event not found"}, status_code=404 content={"success": False, "message": "Live frame not available"},
status_code=404,
) )
headers = { headers = {

View File

@ -9,10 +9,10 @@ import pandas as pd
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, fn, operator from peewee import Case, DoesNotExist, IntegrityError, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import require_role from frigate.api.auth import get_current_user, require_role
from frigate.api.defs.query.review_query_parameters import ( from frigate.api.defs.query.review_query_parameters import (
ReviewActivityMotionQueryParams, ReviewActivityMotionQueryParams,
ReviewQueryParams, ReviewQueryParams,
@ -26,7 +26,7 @@ from frigate.api.defs.response.review_response import (
ReviewSummaryResponse, ReviewSummaryResponse,
) )
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.models import Recordings, ReviewSegment from frigate.models import Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.util.builtin import get_tz_modifiers from frigate.util.builtin import get_tz_modifiers
@ -36,7 +36,15 @@ router = APIRouter(tags=[Tags.review])
@router.get("/review", response_model=list[ReviewSegmentResponse]) @router.get("/review", response_model=list[ReviewSegmentResponse])
def review(params: ReviewQueryParams = Depends()): async def review(
params: ReviewQueryParams = Depends(),
current_user: dict = Depends(get_current_user),
):
if isinstance(current_user, JSONResponse):
return current_user
user_id = current_user["username"]
cameras = params.cameras cameras = params.cameras
labels = params.labels labels = params.labels
zones = params.zones zones = params.zones
@ -74,9 +82,7 @@ def review(params: ReviewQueryParams = Depends()):
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*') | (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
) )
clauses.append(reduce(operator.or_, label_clauses))
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
if zones != "all": if zones != "all":
# use matching so segments with multiple zones # use matching so segments with multiple zones
@ -88,27 +94,52 @@ def review(params: ReviewQueryParams = Depends()):
zone_clauses.append( zone_clauses.append(
(ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*') (ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*')
) )
clauses.append(reduce(operator.or_, zone_clauses))
zone_clause = reduce(operator.or_, zone_clauses)
clauses.append((zone_clause))
if reviewed == 0:
clauses.append((ReviewSegment.has_been_reviewed == False))
if severity: if severity:
clauses.append((ReviewSegment.severity == severity)) clauses.append((ReviewSegment.severity == severity))
review = ( # Join with UserReviewStatus to get per-user review status
ReviewSegment.select() review_query = (
ReviewSegment.select(
ReviewSegment.id,
ReviewSegment.camera,
ReviewSegment.start_time,
ReviewSegment.end_time,
ReviewSegment.severity,
ReviewSegment.thumb_path,
ReviewSegment.data,
fn.COALESCE(UserReviewStatus.has_been_reviewed, False).alias(
"has_been_reviewed"
),
)
.left_outer_join(
UserReviewStatus,
on=(
(ReviewSegment.id == UserReviewStatus.review_segment)
& (UserReviewStatus.user_id == user_id)
),
)
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.order_by(ReviewSegment.severity.asc()) )
# Filter unreviewed items without subquery
if reviewed == 0:
review_query = review_query.where(
(UserReviewStatus.has_been_reviewed == False)
| (UserReviewStatus.has_been_reviewed.is_null())
)
# Apply ordering and limit
review_query = (
review_query.order_by(ReviewSegment.severity.asc())
.order_by(ReviewSegment.start_time.desc()) .order_by(ReviewSegment.start_time.desc())
.limit(limit) .limit(limit)
.dicts() .dicts()
.iterator() .iterator()
) )
return JSONResponse(content=[r for r in review]) return JSONResponse(content=[r for r in review_query])
@router.get("/review_ids", response_model=list[ReviewSegmentResponse]) @router.get("/review_ids", response_model=list[ReviewSegmentResponse])
@ -134,7 +165,15 @@ def review_ids(ids: str):
@router.get("/review/summary", response_model=ReviewSummaryResponse) @router.get("/review/summary", response_model=ReviewSummaryResponse)
def review_summary(params: ReviewSummaryQueryParams = Depends()): async def review_summary(
params: ReviewSummaryQueryParams = Depends(),
current_user: dict = Depends(get_current_user),
):
if isinstance(current_user, JSONResponse):
return current_user
user_id = current_user["username"]
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone) hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp() day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp()
month_ago = (datetime.datetime.now() - datetime.timedelta(days=30)).timestamp() month_ago = (datetime.datetime.now() - datetime.timedelta(days=30)).timestamp()
@ -160,10 +199,7 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
| (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*') | (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*')
) )
clauses.append(reduce(operator.or_, label_clauses))
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
if zones != "all": if zones != "all":
# use matching so segments with multiple zones # use matching so segments with multiple zones
# still match on a search where any zone matches # still match on a search where any zone matches
@ -172,21 +208,20 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
for zone in filtered_zones: for zone in filtered_zones:
zone_clauses.append( zone_clauses.append(
(ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*') ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*'
) )
clauses.append(reduce(operator.or_, zone_clauses))
zone_clause = reduce(operator.or_, zone_clauses) last_24_query = (
clauses.append((zone_clause))
last_24 = (
ReviewSegment.select( ReviewSegment.select(
fn.SUM( fn.SUM(
Case( Case(
None, None,
[ [
( (
(ReviewSegment.severity == SeverityEnum.alert), (ReviewSegment.severity == SeverityEnum.alert)
ReviewSegment.has_been_reviewed, & (UserReviewStatus.has_been_reviewed == True),
1,
) )
], ],
0, 0,
@ -197,8 +232,9 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == SeverityEnum.detection), (ReviewSegment.severity == SeverityEnum.detection)
ReviewSegment.has_been_reviewed, & (UserReviewStatus.has_been_reviewed == True),
1,
) )
], ],
0, 0,
@ -229,6 +265,13 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
) )
).alias("total_detection"), ).alias("total_detection"),
) )
.left_outer_join(
UserReviewStatus,
on=(
(ReviewSegment.id == UserReviewStatus.review_segment)
& (UserReviewStatus.user_id == user_id)
),
)
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.dicts() .dicts()
.get() .get()
@ -248,14 +291,12 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
for label in filtered_labels: for label in filtered_labels:
label_clauses.append( label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') ReviewSegment.data["objects"].cast("text") % f'*"{label}"*'
) )
clauses.append(reduce(operator.or_, label_clauses))
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
day_in_seconds = 60 * 60 * 24 day_in_seconds = 60 * 60 * 24
last_month = ( last_month_query = (
ReviewSegment.select( ReviewSegment.select(
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
@ -271,8 +312,9 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == SeverityEnum.alert), (ReviewSegment.severity == SeverityEnum.alert)
ReviewSegment.has_been_reviewed, & (UserReviewStatus.has_been_reviewed == True),
1,
) )
], ],
0, 0,
@ -283,8 +325,9 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
None, None,
[ [
( (
(ReviewSegment.severity == SeverityEnum.detection), (ReviewSegment.severity == SeverityEnum.detection)
ReviewSegment.has_been_reviewed, & (UserReviewStatus.has_been_reviewed == True),
1,
) )
], ],
0, 0,
@ -315,28 +358,59 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()):
) )
).alias("total_detection"), ).alias("total_detection"),
) )
.left_outer_join(
UserReviewStatus,
on=(
(ReviewSegment.id == UserReviewStatus.review_segment)
& (UserReviewStatus.user_id == user_id)
),
)
.where(reduce(operator.and_, clauses)) .where(reduce(operator.and_, clauses))
.group_by( .group_by(
(ReviewSegment.start_time + seconds_offset).cast("int") / day_in_seconds, (ReviewSegment.start_time + seconds_offset).cast("int") / day_in_seconds
) )
.order_by(ReviewSegment.start_time.desc()) .order_by(ReviewSegment.start_time.desc())
) )
data = { data = {
"last24Hours": last_24, "last24Hours": last_24_query,
} }
for e in last_month.dicts().iterator(): for e in last_month_query.dicts().iterator():
data[e["day"]] = e data[e["day"]] = e
return JSONResponse(content=data) return JSONResponse(content=data)
@router.post("/reviews/viewed", response_model=GenericResponse) @router.post("/reviews/viewed", response_model=GenericResponse)
def set_multiple_reviewed(body: ReviewModifyMultipleBody): async def set_multiple_reviewed(
ReviewSegment.update(has_been_reviewed=True).where( body: ReviewModifyMultipleBody,
ReviewSegment.id << body.ids current_user: dict = Depends(get_current_user),
).execute() ):
if isinstance(current_user, JSONResponse):
return current_user
user_id = current_user["username"]
for review_id in body.ids:
try:
review_status = UserReviewStatus.get(
UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review_id,
)
# If it exists and isnt reviewed, update it
if not review_status.has_been_reviewed:
review_status.has_been_reviewed = True
review_status.save()
except DoesNotExist:
try:
UserReviewStatus.create(
user_id=user_id,
review_segment=ReviewSegment.get(id=review_id),
has_been_reviewed=True,
)
except (DoesNotExist, IntegrityError):
pass
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Reviewed multiple items"}), content=({"success": True, "message": "Reviewed multiple items"}),
@ -389,6 +463,9 @@ def delete_reviews(body: ReviewModifyMultipleBody):
# delete recordings and review segments # delete recordings and review segments
Recordings.delete().where(Recordings.id << recording_ids).execute() Recordings.delete().where(Recordings.id << recording_ids).execute()
ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute() ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute()
UserReviewStatus.delete().where(
UserReviewStatus.review_segment << list_of_ids
).execute()
return JSONResponse( return JSONResponse(
content=({"success": True, "message": "Deleted review items."}), status_code=200 content=({"success": True, "message": "Deleted review items."}), status_code=200
@ -502,7 +579,15 @@ def get_review(review_id: str):
@router.delete("/review/{review_id}/viewed", response_model=GenericResponse) @router.delete("/review/{review_id}/viewed", response_model=GenericResponse)
def set_not_reviewed(review_id: str): async def set_not_reviewed(
review_id: str,
current_user: dict = Depends(get_current_user),
):
if isinstance(current_user, JSONResponse):
return current_user
user_id = current_user["username"]
try: try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id) review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id)
except DoesNotExist: except DoesNotExist:
@ -513,8 +598,15 @@ def set_not_reviewed(review_id: str):
status_code=404, status_code=404,
) )
review.has_been_reviewed = False try:
review.save() user_review = UserReviewStatus.get(
UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review,
)
# we could update here instead of delete if we need
user_review.delete_instance()
except DoesNotExist:
pass # Already effectively "not reviewed"
return JSONResponse( return JSONResponse(
content=({"success": True, "message": f"Set Review {review_id} as not viewed"}), content=({"success": True, "message": f"Set Review {review_id} as not viewed"}),

View File

@ -306,7 +306,6 @@ class CameraState:
# TODO: can i switch to looking this up and only changing when an event ends? # TODO: can i switch to looking this up and only changing when an event ends?
# maintain best objects # maintain best objects
camera_activity: dict[str, list[any]] = { camera_activity: dict[str, list[any]] = {
"enabled": True,
"motion": len(motion_boxes) > 0, "motion": len(motion_boxes) > 0,
"objects": [], "objects": [],
} }

View File

@ -164,8 +164,12 @@ class Dispatcher:
def handle_on_connect(): def handle_on_connect():
camera_status = self.camera_activity.last_camera_activity.copy() camera_status = self.camera_activity.last_camera_activity.copy()
cameras_with_status = camera_status.keys()
for camera in self.config.cameras.keys():
if camera not in cameras_with_status:
camera_status[camera] = {}
for camera in camera_status.keys():
camera_status[camera]["config"] = { camera_status[camera]["config"] = {
"detect": self.config.cameras[camera].detect.enabled, "detect": self.config.cameras[camera].detect.enabled,
"enabled": self.config.cameras[camera].enabled, "enabled": self.config.cameras[camera].enabled,

View File

@ -13,6 +13,7 @@ class EmbeddingsRequestEnum(Enum):
embed_description = "embed_description" embed_description = "embed_description"
embed_thumbnail = "embed_thumbnail" embed_thumbnail = "embed_thumbnail"
generate_search = "generate_search" generate_search = "generate_search"
recognize_face = "recognize_face"
register_face = "register_face" register_face = "register_face"
reprocess_face = "reprocess_face" reprocess_face = "reprocess_face"
reprocess_plate = "reprocess_plate" reprocess_plate = "reprocess_plate"

View File

@ -17,6 +17,10 @@ from frigate.util.builtin import (
) )
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
from ..classification import (
CameraFaceRecognitionConfig,
CameraLicensePlateRecognitionConfig,
)
from .audio import AudioConfig from .audio import AudioConfig
from .birdseye import BirdseyeCameraConfig from .birdseye import BirdseyeCameraConfig
from .detect import DetectConfig from .detect import DetectConfig
@ -52,6 +56,9 @@ class CameraConfig(FrigateBaseModel):
detect: DetectConfig = Field( detect: DetectConfig = Field(
default_factory=DetectConfig, title="Object detection configuration." default_factory=DetectConfig, title="Object detection configuration."
) )
face_recognition: CameraFaceRecognitionConfig = Field(
default_factory=CameraFaceRecognitionConfig, title="Face recognition config."
)
ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.") ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
genai: GenAICameraConfig = Field( genai: GenAICameraConfig = Field(
default_factory=GenAICameraConfig, title="Generative AI configuration." default_factory=GenAICameraConfig, title="Generative AI configuration."
@ -59,6 +66,9 @@ class CameraConfig(FrigateBaseModel):
live: CameraLiveConfig = Field( live: CameraLiveConfig = Field(
default_factory=CameraLiveConfig, title="Live playback settings." default_factory=CameraLiveConfig, title="Live playback settings."
) )
lpr: CameraLicensePlateRecognitionConfig = Field(
default_factory=CameraLicensePlateRecognitionConfig, title="LPR config."
)
motion: Optional[MotionConfig] = Field( motion: Optional[MotionConfig] = Field(
None, title="Motion detection configuration." None, title="Motion detection configuration."
) )

View File

@ -1,11 +1,13 @@
from enum import Enum from enum import Enum
from typing import Dict, List, Optional from typing import Dict, List, Optional
from pydantic import Field from pydantic import ConfigDict, Field
from .base import FrigateBaseModel from .base import FrigateBaseModel
__all__ = [ __all__ = [
"CameraFaceRecognitionConfig",
"CameraLicensePlateRecognitionConfig",
"FaceRecognitionConfig", "FaceRecognitionConfig",
"SemanticSearchConfig", "SemanticSearchConfig",
"LicensePlateRecognitionConfig", "LicensePlateRecognitionConfig",
@ -55,7 +57,13 @@ class FaceRecognitionConfig(FrigateBaseModel):
gt=0.0, gt=0.0,
le=1.0, le=1.0,
) )
threshold: float = Field( detection_threshold: float = Field(
default=0.7,
title="Minimum face detection score required to be considered a face.",
gt=0.0,
le=1.0,
)
recognition_threshold: float = Field(
default=0.9, default=0.9,
title="Minimum face distance score required to be considered a match.", title="Minimum face distance score required to be considered a match.",
gt=0.0, gt=0.0,
@ -64,14 +72,23 @@ class FaceRecognitionConfig(FrigateBaseModel):
min_area: int = Field( min_area: int = Field(
default=500, title="Min area of face box to consider running face recognition." default=500, title="Min area of face box to consider running face recognition."
) )
save_attempts: bool = Field( save_attempts: int = Field(
default=True, title="Save images of face detections for training." default=100, ge=0, title="Number of face attempts to save in the train tab."
) )
blur_confidence_filter: bool = Field( blur_confidence_filter: bool = Field(
default=True, title="Apply blur quality filter to face confidence." default=True, title="Apply blur quality filter to face confidence."
) )
class CameraFaceRecognitionConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable face recognition.")
min_area: int = Field(
default=500, title="Min area of face box to consider running face recognition."
)
model_config = ConfigDict(extra="ignore", protected_namespaces=())
class LicensePlateRecognitionConfig(FrigateBaseModel): class LicensePlateRecognitionConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable license plate recognition.") enabled: bool = Field(default=False, title="Enable license plate recognition.")
detection_threshold: float = Field( detection_threshold: float = Field(
@ -106,3 +123,13 @@ class LicensePlateRecognitionConfig(FrigateBaseModel):
known_plates: Optional[Dict[str, List[str]]] = Field( known_plates: Optional[Dict[str, List[str]]] = Field(
default={}, title="Known plates to track (strings or regular expressions)." default={}, title="Known plates to track (strings or regular expressions)."
) )
class CameraLicensePlateRecognitionConfig(FrigateBaseModel):
enabled: bool = Field(default=False, title="Enable license plate recognition.")
min_area: int = Field(
default=1000,
title="Minimum area of license plate to begin running recognition.",
)
model_config = ConfigDict(extra="ignore", protected_namespaces=())

View File

@ -331,19 +331,6 @@ class FrigateConfig(FrigateBaseModel):
default_factory=TelemetryConfig, title="Telemetry configuration." default_factory=TelemetryConfig, title="Telemetry configuration."
) )
tls: TlsConfig = Field(default_factory=TlsConfig, title="TLS configuration.") tls: TlsConfig = Field(default_factory=TlsConfig, title="TLS configuration.")
classification: ClassificationConfig = Field(
default_factory=ClassificationConfig, title="Object classification config."
)
semantic_search: SemanticSearchConfig = Field(
default_factory=SemanticSearchConfig, title="Semantic search configuration."
)
face_recognition: FaceRecognitionConfig = Field(
default_factory=FaceRecognitionConfig, title="Face recognition config."
)
lpr: LicensePlateRecognitionConfig = Field(
default_factory=LicensePlateRecognitionConfig,
title="License Plate recognition config.",
)
ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.") ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.")
# Detector config # Detector config
@ -395,6 +382,21 @@ class FrigateConfig(FrigateBaseModel):
title="Global timestamp style configuration.", title="Global timestamp style configuration.",
) )
# Classification Config
classification: ClassificationConfig = Field(
default_factory=ClassificationConfig, title="Object classification config."
)
semantic_search: SemanticSearchConfig = Field(
default_factory=SemanticSearchConfig, title="Semantic search configuration."
)
face_recognition: FaceRecognitionConfig = Field(
default_factory=FaceRecognitionConfig, title="Face recognition config."
)
lpr: LicensePlateRecognitionConfig = Field(
default_factory=LicensePlateRecognitionConfig,
title="License Plate recognition config.",
)
camera_groups: Dict[str, CameraGroupConfig] = Field( camera_groups: Dict[str, CameraGroupConfig] = Field(
default_factory=dict, title="Camera group configuration" default_factory=dict, title="Camera group configuration"
) )
@ -435,6 +437,8 @@ class FrigateConfig(FrigateBaseModel):
include={ include={
"audio": ..., "audio": ...,
"birdseye": ..., "birdseye": ...,
"face_recognition": ...,
"lpr": ...,
"record": ..., "record": ...,
"snapshots": ..., "snapshots": ...,
"live": ..., "live": ...,
@ -608,6 +612,11 @@ class FrigateConfig(FrigateBaseModel):
self.model.create_colormap(sorted(self.objects.all_objects)) self.model.create_colormap(sorted(self.objects.all_objects))
self.model.check_and_load_plus_model(self.plus_api) self.model.check_and_load_plus_model(self.plus_api)
if self.plus_api and not self.snapshots.clean_copy:
logger.warning(
"Frigate+ is configured but clean snapshots are not enabled, submissions to Frigate+ will not be possible./"
)
for key, detector in self.detectors.items(): for key, detector in self.detectors.items():
adapter = TypeAdapter(DetectorConfig) adapter = TypeAdapter(DetectorConfig)
model_dict = ( model_dict = (

View File

@ -844,6 +844,8 @@ class LicensePlateProcessingMixin:
def lpr_process(self, obj_data: dict[str, any], frame: np.ndarray): def lpr_process(self, obj_data: dict[str, any], frame: np.ndarray):
"""Look for license plates in image.""" """Look for license plates in image."""
if not self.config.cameras[obj_data["camera"]].lpr.enabled:
return
id = obj_data["id"] id = obj_data["id"]
@ -910,7 +912,10 @@ class LicensePlateProcessingMixin:
# check that license plate is valid # check that license plate is valid
# double the value because we've doubled the size of the car # double the value because we've doubled the size of the car
if license_plate_area < self.lpr_config.min_area * 2: if (
license_plate_area
< self.config.cameras[obj_data["camera"]].lpr.min_area * 2
):
logger.debug("License plate is less than min_area") logger.debug("License plate is less than min_area")
return return
@ -937,18 +942,13 @@ class LicensePlateProcessingMixin:
if not license_plate: if not license_plate:
return return
if license_plate.get("score") < self.lpr_config.detection_threshold:
logger.debug(
f"Plate detection score is less than the threshold ({license_plate['score']:0.2f} < {self.lpr_config.detection_threshold})"
)
return
license_plate_box = license_plate.get("box") license_plate_box = license_plate.get("box")
# check that license plate is valid # check that license plate is valid
if ( if (
not license_plate_box not license_plate_box
or area(license_plate_box) < self.lpr_config.min_area or area(license_plate_box)
< self.config.cameras[obj_data["camera"]].lpr.min_area
): ):
logger.debug(f"Invalid license plate box {license_plate}") logger.debug(f"Invalid license plate box {license_plate}")
return return

View File

@ -115,10 +115,10 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
x:x2, x:x2,
] ]
cv2.imwrite("/media/frigate/test_class.png", input) if input.shape != (224, 224):
input = cv2.resize(input, (224, 224))
input = np.expand_dims(input, axis=0) input = np.expand_dims(input, axis=0)
self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input) self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input)
self.interpreter.invoke() self.interpreter.invoke()
res: np.ndarray = self.interpreter.get_tensor( res: np.ndarray = self.interpreter.get_tensor(
@ -144,7 +144,8 @@ class BirdRealTimeProcessor(RealTimeProcessorApi):
return return
self.sub_label_publisher.publish( self.sub_label_publisher.publish(
EventMetadataTypeEnum.sub_label, (id, self.labelmap[best_id], score) EventMetadataTypeEnum.sub_label,
(obj_data["id"], self.labelmap[best_id], score),
) )
self.detected_birds[obj_data["id"]] = score self.detected_birds[obj_data["id"]] = score

View File

@ -27,6 +27,7 @@ from .api import RealTimeProcessorApi
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAX_DETECTION_HEIGHT = 1080
MIN_MATCHING_FACES = 2 MIN_MATCHING_FACES = 2
@ -88,7 +89,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"), os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"),
config="", config="",
input_size=(320, 320), input_size=(320, 320),
score_threshold=0.8, score_threshold=0.5,
nms_threshold=0.3, nms_threshold=0.3,
) )
self.landmark_detector = cv2.face.createFacemarkLBF() self.landmark_detector = cv2.face.createFacemarkLBF()
@ -212,11 +213,23 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
self.face_recognizer = None self.face_recognizer = None
self.label_map = {} self.label_map = {}
def __detect_face(self, input: np.ndarray) -> tuple[int, int, int, int]: def __detect_face(
self, input: np.ndarray, threshold: float
) -> tuple[int, int, int, int]:
"""Detect faces in input image.""" """Detect faces in input image."""
if not self.face_detector: if not self.face_detector:
return None return None
# YN face detector fails at extreme definitions
# this rescales to a size that can properly detect faces
# still retaining plenty of detail
if input.shape[0] > MAX_DETECTION_HEIGHT:
scale_factor = MAX_DETECTION_HEIGHT / input.shape[0]
new_width = int(scale_factor * input.shape[1])
input = cv2.resize(input, (new_width, MAX_DETECTION_HEIGHT))
else:
scale_factor = 1
self.face_detector.setInputSize((input.shape[1], input.shape[0])) self.face_detector.setInputSize((input.shape[1], input.shape[0]))
faces = self.face_detector.detect(input) faces = self.face_detector.detect(input)
@ -226,11 +239,14 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
face = None face = None
for _, potential_face in enumerate(faces[1]): for _, potential_face in enumerate(faces[1]):
if potential_face[-1] < threshold:
continue
raw_bbox = potential_face[0:4].astype(np.uint16) raw_bbox = potential_face[0:4].astype(np.uint16)
x: int = max(raw_bbox[0], 0) x: int = int(max(raw_bbox[0], 0) / scale_factor)
y: int = max(raw_bbox[1], 0) y: int = int(max(raw_bbox[1], 0) / scale_factor)
w: int = raw_bbox[2] w: int = int(raw_bbox[2] / scale_factor)
h: int = raw_bbox[3] h: int = int(raw_bbox[3] / scale_factor)
bbox = (x, y, x + w, y + h) bbox = (x, y, x + w, y + h)
if face is None or area(bbox) > area(face): if face is None or area(bbox) > area(face):
@ -272,6 +288,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
def process_frame(self, obj_data: dict[str, any], frame: np.ndarray): def process_frame(self, obj_data: dict[str, any], frame: np.ndarray):
"""Look for faces in image.""" """Look for faces in image."""
if not self.config.cameras[obj_data["camera"]].face_recognition.enabled:
return
start = datetime.datetime.now().timestamp() start = datetime.datetime.now().timestamp()
id = obj_data["id"] id = obj_data["id"]
@ -300,7 +319,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
left, top, right, bottom = person_box left, top, right, bottom = person_box
person = rgb[top:bottom, left:right] person = rgb[top:bottom, left:right]
face_box = self.__detect_face(person) face_box = self.__detect_face(person, self.face_config.detection_threshold)
if not face_box: if not face_box:
logger.debug("Detected no faces for person object.") logger.debug("Detected no faces for person object.")
@ -332,7 +351,11 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
face_box = face.get("box") face_box = face.get("box")
# check that face is valid # check that face is valid
if not face_box or area(face_box) < self.config.face_recognition.min_area: if (
not face_box
or area(face_box)
< self.config.cameras[obj_data["camera"]].face_recognition.min_area
):
logger.debug(f"Invalid face box {face}") logger.debug(f"Invalid face box {face}")
return return
@ -367,9 +390,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
os.makedirs(folder, exist_ok=True) os.makedirs(folder, exist_ok=True)
cv2.imwrite(file, face_frame) cv2.imwrite(file, face_frame)
if score < self.config.face_recognition.threshold: if score < self.config.face_recognition.recognition_threshold:
logger.debug( logger.debug(
f"Recognized face distance {score} is less than threshold {self.config.face_recognition.threshold}" f"Recognized face distance {score} is less than threshold {self.config.face_recognition.recognition_threshold}"
) )
self.__update_metrics(datetime.datetime.now().timestamp() - start) self.__update_metrics(datetime.datetime.now().timestamp() - start)
return return
@ -390,6 +413,28 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
def handle_request(self, topic, request_data) -> dict[str, any] | None: def handle_request(self, topic, request_data) -> dict[str, any] | None:
if topic == EmbeddingsRequestEnum.clear_face_classifier.value: if topic == EmbeddingsRequestEnum.clear_face_classifier.value:
self.__clear_classifier() self.__clear_classifier()
elif topic == EmbeddingsRequestEnum.recognize_face.value:
img = cv2.imdecode(
np.frombuffer(base64.b64decode(request_data["image"]), dtype=np.uint8),
cv2.IMREAD_COLOR,
)
# detect faces with lower confidence since we expect the face
# to be visible in uploaded images
face_box = self.__detect_face(img, 0.5)
if not face_box:
return {"message": "No face was detected.", "success": False}
face = img[face_box[1] : face_box[3], face_box[0] : face_box[2]]
res = self.__classify_face(face)
if not res:
return {"success": False, "message": "No face was recognized."}
sub_label, score = res
return {"success": True, "score": score, "face_name": sub_label}
elif topic == EmbeddingsRequestEnum.register_face.value: elif topic == EmbeddingsRequestEnum.register_face.value:
rand_id = "".join( rand_id = "".join(
random.choices(string.ascii_lowercase + string.digits, k=6) random.choices(string.ascii_lowercase + string.digits, k=6)
@ -406,7 +451,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
), ),
cv2.IMREAD_COLOR, cv2.IMREAD_COLOR,
) )
face_box = self.__detect_face(img)
# detect faces with lower confidence since we expect the face
# to be visible in uploaded images
face_box = self.__detect_face(img, 0.5)
if not face_box: if not face_box:
return { return {
@ -458,11 +506,22 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
if self.config.face_recognition.save_attempts: if self.config.face_recognition.save_attempts:
# write face to library # write face to library
folder = os.path.join(FACE_DIR, "train") folder = os.path.join(FACE_DIR, "train")
os.makedirs(folder, exist_ok=True)
new_file = os.path.join( new_file = os.path.join(
folder, f"{id}-{sub_label}-{score}-{face_score}.webp" folder, f"{id}-{sub_label}-{score}-{face_score}.webp"
) )
shutil.move(current_file, new_file) shutil.move(current_file, new_file)
files = sorted(
filter(lambda f: (f.endswith(".webp")), os.listdir(folder)),
key=lambda f: os.path.getctime(os.path.join(folder, f)),
reverse=True,
)
# delete oldest face image if maximum is reached
if len(files) > self.config.face_recognition.save_attempts:
os.unlink(os.path.join(folder, files[-1]))
def expire_object(self, object_id: str): def expire_object(self, object_id: str):
if object_id in self.detected_faces: if object_id in self.detected_faces:
self.detected_faces.pop(object_id) self.detected_faces.pop(object_id)

View File

@ -1,4 +1,5 @@
import logging import logging
import os
import numpy as np import numpy as np
from pydantic import Field from pydantic import Field
@ -45,9 +46,17 @@ class EdgeTpuTfl(DetectionApi):
experimental_delegates=[edge_tpu_delegate], experimental_delegates=[edge_tpu_delegate],
) )
except ValueError: except ValueError:
logger.error( _, ext = os.path.splitext(detector_config.model.path)
"No EdgeTPU was detected. If you do not have a Coral device yet, you must configure CPU detectors."
) if ext and ext != ".tflite":
logger.error(
"Incorrect model used with EdgeTPU. Only .tflite models can be used with a Coral EdgeTPU."
)
else:
logger.error(
"No EdgeTPU was detected. If you do not have a Coral device yet, you must configure CPU detectors."
)
raise raise
self.interpreter.allocate_tensors() self.interpreter.allocate_tensors()

View File

@ -10,7 +10,7 @@ from typing_extensions import Literal
from frigate.const import MODEL_CACHE_DIR from frigate.const import MODEL_CACHE_DIR
from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detection_api import DetectionApi
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
from frigate.util.model import post_process_yolov9 from frigate.util.model import post_process_dfine, post_process_yolov9
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -29,6 +29,7 @@ class OvDetector(DetectionApi):
ModelTypeEnum.yolonas, ModelTypeEnum.yolonas,
ModelTypeEnum.yolov9, ModelTypeEnum.yolov9,
ModelTypeEnum.yolox, ModelTypeEnum.yolox,
ModelTypeEnum.dfine,
] ]
def __init__(self, detector_config: OvDetectorConfig): def __init__(self, detector_config: OvDetectorConfig):
@ -163,6 +164,21 @@ class OvDetector(DetectionApi):
infer_request = self.interpreter.create_infer_request() infer_request = self.interpreter.create_infer_request()
# TODO: see if we can use shared_memory=True # TODO: see if we can use shared_memory=True
input_tensor = ov.Tensor(array=tensor_input) input_tensor = ov.Tensor(array=tensor_input)
if self.ov_model_type == ModelTypeEnum.dfine:
infer_request.set_tensor("images", input_tensor)
target_sizes_tensor = ov.Tensor(
np.array([[self.h, self.w]], dtype=np.int64)
)
infer_request.set_tensor("orig_target_sizes", target_sizes_tensor)
infer_request.infer()
tensor_output = (
infer_request.get_output_tensor(0).data,
infer_request.get_output_tensor(1).data,
infer_request.get_output_tensor(2).data,
)
return post_process_dfine(tensor_output, self.w, self.h)
infer_request.infer(input_tensor) infer_request.infer(input_tensor)
detections = np.zeros((20, 6), np.float32) detections = np.zeros((20, 6), np.float32)

View File

@ -197,6 +197,14 @@ class EmbeddingsContext:
}, },
) )
def recognize_face(self, image_data: bytes) -> dict[str, any]:
return self.requestor.send_data(
EmbeddingsRequestEnum.recognize_face.value,
{
"image": base64.b64encode(image_data).decode("ASCII"),
},
)
def get_face_ids(self, name: str) -> list[str]: def get_face_ids(self, name: str) -> list[str]:
sql_query = f""" sql_query = f"""
SELECT SELECT
@ -225,6 +233,9 @@ class EmbeddingsContext:
if os.path.isfile(file_path): if os.path.isfile(file_path):
os.unlink(file_path) os.unlink(file_path)
if len(os.listdir(folder)) == 0:
os.rmdir(folder)
def update_description(self, event_id: str, description: str) -> None: def update_description(self, event_id: str, description: str) -> None:
self.requestor.send_data( self.requestor.send_data(
EmbeddingsRequestEnum.embed_description.value, EmbeddingsRequestEnum.embed_description.value,

View File

@ -3,6 +3,7 @@ from peewee import (
CharField, CharField,
DateTimeField, DateTimeField,
FloatField, FloatField,
ForeignKeyField,
IntegerField, IntegerField,
Model, Model,
TextField, TextField,
@ -92,12 +93,20 @@ class ReviewSegment(Model): # type: ignore[misc]
camera = CharField(index=True, max_length=20) camera = CharField(index=True, max_length=20)
start_time = DateTimeField() start_time = DateTimeField()
end_time = DateTimeField() end_time = DateTimeField()
has_been_reviewed = BooleanField(default=False)
severity = CharField(max_length=30) # alert, detection severity = CharField(max_length=30) # alert, detection
thumb_path = CharField(unique=True) thumb_path = CharField(unique=True)
data = JSONField() # additional data about detection like list of labels, zone, areas of significant motion data = JSONField() # additional data about detection like list of labels, zone, areas of significant motion
class UserReviewStatus(Model): # type: ignore[misc]
user_id = CharField(max_length=30)
review_segment = ForeignKeyField(ReviewSegment, backref="user_reviews")
has_been_reviewed = BooleanField(default=False)
class Meta:
indexes = ((("user_id", "review_segment"), True),)
class Previews(Model): # type: ignore[misc] class Previews(Model): # type: ignore[misc]
id = CharField(null=False, primary_key=True, max_length=30) id = CharField(null=False, primary_key=True, max_length=30)
camera = CharField(index=True, max_length=20) camera = CharField(index=True, max_length=20)

View File

@ -584,19 +584,31 @@ class PtzAutoTracker:
# Extract areas and calculate weighted average # Extract areas and calculate weighted average
# grab the largest dimension of the bounding box and create a square from that # grab the largest dimension of the bounding box and create a square from that
# Filter out the initial frame and use a recent time window
current_time = obj.obj_data["frame_time"]
time_window = 1.5 # seconds
history = [
entry
for entry in self.tracked_object_history[camera]
if not entry.get("is_initial_frame", False)
and current_time - entry["frame_time"] <= time_window
]
if not history: # Fallback to latest if no recent entries
history = [self.tracked_object_history[camera][-1]]
areas = [ areas = [
{ {
"frame_time": obj["frame_time"], "frame_time": entry["frame_time"],
"box": obj["box"], "box": entry["box"],
"area": max( "area": max(
obj["box"][2] - obj["box"][0], obj["box"][3] - obj["box"][1] entry["box"][2] - entry["box"][0], entry["box"][3] - entry["box"][1]
) )
** 2, ** 2,
} }
for obj in self.tracked_object_history[camera] for entry in history
] ]
filtered_areas = remove_outliers(areas) if len(areas) >= 2 else areas filtered_areas = remove_outliers(areas) if len(areas) > 3 else areas
# Filter entries that are not touching the frame edge # Filter entries that are not touching the frame edge
filtered_areas_not_touching_edge = [ filtered_areas_not_touching_edge = [

View File

@ -2,6 +2,7 @@
import asyncio import asyncio
import logging import logging
import time
from enum import Enum from enum import Enum
from importlib.util import find_spec from importlib.util import find_spec
from pathlib import Path from pathlib import Path
@ -39,6 +40,10 @@ class OnvifController:
self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetrics] self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetrics]
) -> None: ) -> None:
self.cams: dict[str, ONVIFCamera] = {} self.cams: dict[str, ONVIFCamera] = {}
self.failed_cams: dict[str, dict] = {}
self.max_retries = 5
self.reset_timeout = 900 # 15 minutes
self.config = config self.config = config
self.ptz_metrics = ptz_metrics self.ptz_metrics = ptz_metrics
@ -47,26 +52,37 @@ class OnvifController:
continue continue
if cam.onvif.host: if cam.onvif.host:
try: result = self._create_onvif_camera(cam_name, cam)
self.cams[cam_name] = { if result:
"onvif": ONVIFCamera( self.cams[cam_name] = result
cam.onvif.host,
cam.onvif.port, def _create_onvif_camera(self, cam_name: str, cam) -> dict | None:
cam.onvif.user, """Create an ONVIF camera instance and handle failures."""
cam.onvif.password, try:
wsdl_dir=str( return {
Path(find_spec("onvif").origin).parent / "wsdl" "onvif": ONVIFCamera(
), cam.onvif.host,
adjust_time=cam.onvif.ignore_time_mismatch, cam.onvif.port,
encrypt=not cam.onvif.tls_insecure, cam.onvif.user,
), cam.onvif.password,
"init": False, wsdl_dir=str(Path(find_spec("onvif").origin).parent / "wsdl"),
"active": False, adjust_time=cam.onvif.ignore_time_mismatch,
"features": [], encrypt=not cam.onvif.tls_insecure,
"presets": {}, ),
} "init": False,
except ONVIFError as e: "active": False,
logger.error(f"Onvif connection to {cam.name} failed: {e}") "features": [],
"presets": {},
}
except ONVIFError as e:
logger.error(f"Failed to create ONVIF camera instance for {cam_name}: {e}")
# track initial failures
self.failed_cams[cam_name] = {
"retry_attempts": 0,
"last_error": str(e),
"last_attempt": time.time(),
}
return None
async def _init_onvif(self, camera_name: str) -> bool: async def _init_onvif(self, camera_name: str) -> bool:
onvif: ONVIFCamera = self.cams[camera_name]["onvif"] onvif: ONVIFCamera = self.cams[camera_name]["onvif"]
@ -548,7 +564,7 @@ class OnvifController:
self, camera_name: str, command: OnvifCommandEnum, param: str = "" self, camera_name: str, command: OnvifCommandEnum, param: str = ""
) -> None: ) -> None:
if camera_name not in self.cams.keys(): if camera_name not in self.cams.keys():
logger.error(f"Onvif is not setup for {camera_name}") logger.error(f"ONVIF is not configured for {camera_name}")
return return
if not self.cams[camera_name]["init"]: if not self.cams[camera_name]["init"]:
@ -576,23 +592,94 @@ class OnvifController:
except ONVIFError as e: except ONVIFError as e:
logger.error(f"Unable to handle onvif command: {e}") logger.error(f"Unable to handle onvif command: {e}")
def get_camera_info(self, camera_name: str) -> dict[str, any]: async def get_camera_info(self, camera_name: str) -> dict[str, any]:
if camera_name not in self.cams.keys(): """
logger.debug(f"Onvif is not setup for {camera_name}") Get ptz capabilities and presets, attempting to reconnect if ONVIF is configured
but not initialized.
Returns camera details including features and presets if available.
"""
if not self.config.cameras[camera_name].enabled:
logger.debug(
f"Camera {camera_name} disabled, won't try to initialize ONVIF"
)
return {} return {}
if not self.cams[camera_name]["init"]: if camera_name not in self.cams and (
asyncio.run(self._init_onvif(camera_name)) camera_name not in self.config.cameras
or not self.config.cameras[camera_name].onvif.host
):
logger.debug(f"ONVIF is not configured for {camera_name}")
return {}
return { if camera_name in self.cams and self.cams[camera_name]["init"]:
"name": camera_name, return {
"features": self.cams[camera_name]["features"], "name": camera_name,
"presets": list(self.cams[camera_name]["presets"].keys()), "features": self.cams[camera_name]["features"],
} "presets": list(self.cams[camera_name]["presets"].keys()),
}
if camera_name not in self.cams and camera_name in self.config.cameras:
cam = self.config.cameras[camera_name]
result = self._create_onvif_camera(camera_name, cam)
if result:
self.cams[camera_name] = result
else:
return {}
# Reset retry count after timeout
attempts = self.failed_cams.get(camera_name, {}).get("retry_attempts", 0)
last_attempt = self.failed_cams.get(camera_name, {}).get("last_attempt", 0)
if last_attempt and (time.time() - last_attempt) > self.reset_timeout:
logger.debug(f"Resetting retry count for {camera_name} after timeout")
attempts = 0
self.failed_cams[camera_name]["retry_attempts"] = 0
# Attempt initialization/reconnection
if attempts < self.max_retries:
logger.info(
f"Attempting ONVIF initialization for {camera_name} (retry {attempts + 1}/{self.max_retries})"
)
try:
if await self._init_onvif(camera_name):
if camera_name in self.failed_cams:
del self.failed_cams[camera_name]
return {
"name": camera_name,
"features": self.cams[camera_name]["features"],
"presets": list(self.cams[camera_name]["presets"].keys()),
}
else:
logger.warning(f"ONVIF initialization failed for {camera_name}")
except Exception as e:
logger.error(
f"Error during ONVIF initialization for {camera_name}: {e}"
)
if camera_name not in self.failed_cams:
self.failed_cams[camera_name] = {"retry_attempts": 0}
self.failed_cams[camera_name].update(
{
"retry_attempts": attempts + 1,
"last_error": str(e),
"last_attempt": time.time(),
}
)
if attempts >= self.max_retries:
remaining_time = max(
0, int((self.reset_timeout - (time.time() - last_attempt)) / 60)
)
logger.error(
f"Too many ONVIF initialization attempts for {camera_name}, retry in {remaining_time} minute{'s' if remaining_time != 1 else ''}"
)
logger.debug(f"Could not initialize ONVIF for {camera_name}")
return {}
def get_service_capabilities(self, camera_name: str) -> None: def get_service_capabilities(self, camera_name: str) -> None:
if camera_name not in self.cams.keys(): if camera_name not in self.cams.keys():
logger.error(f"Onvif is not setup for {camera_name}") logger.error(f"ONVIF is not configured for {camera_name}")
return {} return {}
if not self.cams[camera_name]["init"]: if not self.cams[camera_name]["init"]:
@ -622,7 +709,7 @@ class OnvifController:
def get_camera_status(self, camera_name: str) -> None: def get_camera_status(self, camera_name: str) -> None:
if camera_name not in self.cams.keys(): if camera_name not in self.cams.keys():
logger.error(f"Onvif is not setup for {camera_name}") logger.error(f"ONVIF is not configured for {camera_name}")
return {} return {}
if not self.cams[camera_name]["init"]: if not self.cams[camera_name]["init"]:

View File

@ -12,7 +12,7 @@ from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR
from frigate.models import Previews, Recordings, ReviewSegment from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
from frigate.record.util import remove_empty_directories, sync_recordings from frigate.record.util import remove_empty_directories, sync_recordings
from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time
@ -90,6 +90,10 @@ class RecordingCleanup(threading.Thread):
ReviewSegment.delete().where( ReviewSegment.delete().where(
ReviewSegment.id << deleted_reviews_list[i : i + max_deletes] ReviewSegment.id << deleted_reviews_list[i : i + max_deletes]
).execute() ).execute()
UserReviewStatus.delete().where(
UserReviewStatus.review_segment
<< deleted_reviews_list[i : i + max_deletes]
).execute()
def expire_existing_camera_recordings( def expire_existing_camera_recordings(
self, expire_date: float, config: CameraConfig, reviews: ReviewSegment self, expire_date: float, config: CameraConfig, reviews: ReviewSegment

View File

@ -19,7 +19,6 @@ from frigate.const import (
CACHE_DIR, CACHE_DIR,
CLIPS_DIR, CLIPS_DIR,
EXPORT_DIR, EXPORT_DIR,
FFMPEG_HVC1_ARGS,
MAX_PLAYLIST_SECONDS, MAX_PLAYLIST_SECONDS,
PREVIEW_FRAME_TYPE, PREVIEW_FRAME_TYPE,
) )
@ -233,9 +232,6 @@ class RecordingExporter(threading.Thread):
) )
).split(" ") ).split(" ")
if self.config.ffmpeg.apple_compatibility:
ffmpeg_cmd += FFMPEG_HVC1_ARGS
# add metadata # add metadata
title = f"Frigate Recording for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}" title = f"Frigate Recording for {self.camera}, {self.get_datetime_from_timestamp(self.start_time)} - {self.get_datetime_from_timestamp(self.end_time)}"
ffmpeg_cmd.extend(["-metadata", f"title={title}"]) ffmpeg_cmd.extend(["-metadata", f"title={title}"])

View File

@ -181,6 +181,9 @@ class ReviewSegmentMaintainer(threading.Thread):
} }
), ),
) )
self.requestor.send_data(
f"{segment.camera}/review_status", segment.severity.value.upper()
)
def _publish_segment_update( def _publish_segment_update(
self, self,
@ -206,6 +209,9 @@ class ReviewSegmentMaintainer(threading.Thread):
} }
), ),
) )
self.requestor.send_data(
f"{segment.camera}/review_status", segment.severity.value.upper()
)
def _publish_segment_end( def _publish_segment_end(
self, self,
@ -225,6 +231,7 @@ class ReviewSegmentMaintainer(threading.Thread):
} }
), ),
) )
self.requestor.send_data(f"{segment.camera}/review_status", "NONE")
self.active_review_segments[segment.camera] = None self.active_review_segments[segment.camera] = None
def end_segment(self, camera: str) -> None: def end_segment(self, camera: str) -> None:
@ -253,7 +260,8 @@ class ReviewSegmentMaintainer(threading.Thread):
if len(active_objects) > 0: if len(active_objects) > 0:
has_activity = True has_activity = True
should_update = False should_update_image = False
should_update_state = False
if frame_time > segment.last_update: if frame_time > segment.last_update:
segment.last_update = frame_time segment.last_update = frame_time
@ -284,7 +292,8 @@ class ReviewSegmentMaintainer(threading.Thread):
and camera_config.review.alerts.enabled and camera_config.review.alerts.enabled
): ):
segment.severity = SeverityEnum.alert segment.severity = SeverityEnum.alert
should_update = True should_update_state = True
should_update_image = True
# keep zones up to date # keep zones up to date
if len(object["current_zones"]) > 0: if len(object["current_zones"]) > 0:
@ -293,17 +302,24 @@ class ReviewSegmentMaintainer(threading.Thread):
segment.zones.append(zone) segment.zones.append(zone)
if len(active_objects) > segment.frame_active_count: if len(active_objects) > segment.frame_active_count:
should_update = True should_update_state = True
should_update_image = True
if should_update: if prev_data["data"]["sub_labels"] != list(segment.sub_labels.values()):
should_update_state = True
if should_update_state:
try: try:
yuv_frame = self.frame_manager.get( if should_update_image:
frame_name, camera_config.frame_shape_yuv yuv_frame = self.frame_manager.get(
) frame_name, camera_config.frame_shape_yuv
)
if yuv_frame is None: if yuv_frame is None:
logger.debug(f"Failed to get frame {frame_name} from SHM") logger.debug(f"Failed to get frame {frame_name} from SHM")
return return
else:
yuv_frame = None
self._publish_segment_update( self._publish_segment_update(
segment, camera_config, yuv_frame, active_objects, prev_data segment, camera_config, yuv_frame, active_objects, prev_data

View File

@ -12,7 +12,8 @@ from prometheus_client.core import (
class CustomCollector(object): class CustomCollector(object):
def __init__(self, _url): def __init__(self, _url):
self.process_stats = {} self.complete_stats = {} # Store complete stats data
self.process_stats = {} # Keep for CPU processing
self.previous_event_id = None self.previous_event_id = None
self.previous_event_start_time = None self.previous_event_start_time = None
self.all_events = {} self.all_events = {}
@ -34,30 +35,34 @@ class CustomCollector(object):
process_name, process_name,
cpu_or_memory, cpu_or_memory,
process_type, process_type,
cpu_usages,
): ):
try: try:
pid = str(camera_stats[pid_name]) pid = str(camera_stats[pid_name])
label_values = [pid, camera_name, process_name, process_type] label_values = [pid, camera_name, process_name, process_type]
try: try:
# new frigate:0.13.0-beta3 stat 'cmdline' # new frigate:0.13.0-beta3 stat 'cmdline'
label_values.append(self.process_stats[pid]["cmdline"]) label_values.append(cpu_usages[pid]["cmdline"])
except KeyError: except KeyError:
pass pass
metric.add_metric(label_values, self.process_stats[pid][cpu_or_memory]) metric.add_metric(label_values, cpu_usages[pid][cpu_or_memory])
del self.process_stats[pid][cpu_or_memory] # Don't modify the original data
except (KeyError, TypeError, IndexError): except (KeyError, TypeError, IndexError):
pass pass
def collect(self): def collect(self):
stats = self.process_stats # Assign self.process_stats to local variable stats # Work with a copy of the complete stats
stats = self.complete_stats.copy()
# Create a local copy of CPU usages to work with
cpu_usages = {}
try: try:
self.process_stats = stats["cpu_usages"] cpu_usages = stats.get("cpu_usages", {}).copy()
except KeyError: except (KeyError, AttributeError):
pass pass
# process stats for cameras, detectors and other # process stats for cameras, detectors and other
cpu_usages = GaugeMetricFamily( cpu_usages_metric = GaugeMetricFamily(
"frigate_cpu_usage_percent", "frigate_cpu_usage_percent",
"Process CPU usage %", "Process CPU usage %",
labels=["pid", "name", "process", "type", "cmdline"], labels=["pid", "name", "process", "type", "cmdline"],
@ -121,25 +126,34 @@ class CustomCollector(object):
self.add_metric(skipped_fps, [camera_name], camera_stats, "skipped_fps") self.add_metric(skipped_fps, [camera_name], camera_stats, "skipped_fps")
self.add_metric_process( self.add_metric_process(
cpu_usages, cpu_usages_metric,
camera_stats, camera_stats,
camera_name, camera_name,
"ffmpeg_pid", "ffmpeg_pid",
"ffmpeg", "ffmpeg",
"cpu", "cpu",
"Camera", "Camera",
cpu_usages,
) )
self.add_metric_process( self.add_metric_process(
cpu_usages, cpu_usages_metric,
camera_stats, camera_stats,
camera_name, camera_name,
"capture_pid", "capture_pid",
"capture", "capture",
"cpu", "cpu",
"Camera", "Camera",
cpu_usages,
) )
self.add_metric_process( self.add_metric_process(
cpu_usages, camera_stats, camera_name, "pid", "detect", "cpu", "Camera" cpu_usages_metric,
camera_stats,
camera_name,
"pid",
"detect",
"cpu",
"Camera",
cpu_usages,
) )
self.add_metric_process( self.add_metric_process(
@ -150,6 +164,7 @@ class CustomCollector(object):
"ffmpeg", "ffmpeg",
"mem", "mem",
"Camera", "Camera",
cpu_usages,
) )
self.add_metric_process( self.add_metric_process(
mem_usages, mem_usages,
@ -159,9 +174,17 @@ class CustomCollector(object):
"capture", "capture",
"mem", "mem",
"Camera", "Camera",
cpu_usages,
) )
self.add_metric_process( self.add_metric_process(
mem_usages, camera_stats, camera_name, "pid", "detect", "mem", "Camera" mem_usages,
camera_stats,
camera_name,
"pid",
"detect",
"mem",
"Camera",
cpu_usages,
) )
yield audio_dBFS yield audio_dBFS
@ -239,13 +262,14 @@ class CustomCollector(object):
"detection_start", "detection_start",
) )
self.add_metric_process( self.add_metric_process(
cpu_usages, cpu_usages_metric,
stats["detectors"], stats["detectors"],
detector_name, detector_name,
"pid", "pid",
"detect", "detect",
"cpu", "cpu",
"Detector", "Detector",
cpu_usages,
) )
self.add_metric_process( self.add_metric_process(
mem_usages, mem_usages,
@ -255,6 +279,7 @@ class CustomCollector(object):
"detect", "detect",
"mem", "mem",
"Detector", "Detector",
cpu_usages,
) )
except KeyError: except KeyError:
pass pass
@ -272,10 +297,10 @@ class CustomCollector(object):
label.append(detector_name) # name label label.append(detector_name) # name label
label.append(detector_name) # process label label.append(detector_name) # process label
label.append("detectors") # type label label.append("detectors") # type label
label.append(self.process_stats[p_pid]["cmdline"]) # cmdline label label.append(cpu_usages[p_pid]["cmdline"]) # cmdline label
self.add_metric(cpu_usages, label, self.process_stats[p_pid], "cpu") self.add_metric(cpu_usages_metric, label, cpu_usages[p_pid], "cpu")
self.add_metric(mem_usages, label, self.process_stats[p_pid], "mem") self.add_metric(mem_usages, label, cpu_usages[p_pid], "mem")
del self.process_stats[p_pid] # Don't modify the original data
except KeyError: except KeyError:
pass pass
@ -292,10 +317,10 @@ class CustomCollector(object):
label.append(process_name) # name label label.append(process_name) # name label
label.append(process_name) # process label label.append(process_name) # process label
label.append(process_name) # type label label.append(process_name) # type label
label.append(self.process_stats[p_pid]["cmdline"]) # cmdline label label.append(cpu_usages[p_pid]["cmdline"]) # cmdline label
self.add_metric(cpu_usages, label, self.process_stats[p_pid], "cpu") self.add_metric(cpu_usages_metric, label, cpu_usages[p_pid], "cpu")
self.add_metric(mem_usages, label, self.process_stats[p_pid], "mem") self.add_metric(mem_usages, label, cpu_usages[p_pid], "mem")
del self.process_stats[p_pid] # Don't modify the original data
except KeyError: except KeyError:
pass pass
@ -304,7 +329,7 @@ class CustomCollector(object):
# remaining process stats # remaining process stats
try: try:
for process_id, pid_stats in self.process_stats.items(): for process_id, pid_stats in cpu_usages.items():
label = [process_id] # pid label label = [process_id] # pid label
try: try:
# new frigate:0.13.0-beta3 stat 'cmdline' # new frigate:0.13.0-beta3 stat 'cmdline'
@ -314,12 +339,12 @@ class CustomCollector(object):
label.append(pid_stats["cmdline"]) # cmdline label label.append(pid_stats["cmdline"]) # cmdline label
except KeyError: except KeyError:
pass pass
self.add_metric(cpu_usages, label, pid_stats, "cpu") self.add_metric(cpu_usages_metric, label, pid_stats, "cpu")
self.add_metric(mem_usages, label, pid_stats, "mem") self.add_metric(mem_usages, label, pid_stats, "mem")
except KeyError: except KeyError:
pass pass
yield cpu_usages yield cpu_usages_metric
yield mem_usages yield mem_usages
# gpu stats # gpu stats
@ -481,10 +506,13 @@ REGISTRY.register(collector)
def update_metrics(stats): def update_metrics(stats):
"""Updates the Prometheus metrics with the given stats data.""" """Updates the Prometheus metrics with the given stats data."""
try: try:
collector.process_stats = stats # Directly assign the stats data # Store the complete stats for later use by collect()
# Important: Since we are not fetching from URL, we need to manually call collect collector.complete_stats = stats.copy()
for _ in collector.collect():
pass # For backwards compatibility
collector.process_stats = stats.copy()
# No need to call collect() here - it will be called by get_metrics()
except Exception as e: except Exception as e:
logging.error(f"Error updating metrics: {e}") logging.error(f"Error updating metrics: {e}")

View File

@ -157,16 +157,14 @@ class BaseTestHttp(unittest.TestCase):
start_time: float = datetime.datetime.now().timestamp(), start_time: float = datetime.datetime.now().timestamp(),
end_time: float = datetime.datetime.now().timestamp() + 20, end_time: float = datetime.datetime.now().timestamp() + 20,
severity: SeverityEnum = SeverityEnum.alert, severity: SeverityEnum = SeverityEnum.alert,
has_been_reviewed: bool = False,
data: Json = {}, data: Json = {},
) -> Event: ) -> ReviewSegment:
"""Inserts a review segment model with a given id.""" """Inserts a review segment model with a given id."""
return ReviewSegment.insert( return ReviewSegment.insert(
id=id, id=id,
camera="front_door", camera="front_door",
start_time=start_time, start_time=start_time,
end_time=end_time, end_time=end_time,
has_been_reviewed=has_been_reviewed,
severity=severity, severity=severity,
thumb_path=False, thumb_path=False,
data=data, data=data,

View File

@ -1,16 +1,29 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from peewee import DoesNotExist
from frigate.models import Event, Recordings, ReviewSegment from frigate.api.auth import get_current_user
from frigate.models import Event, Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.test.http_api.base_http_test import BaseTestHttp from frigate.test.http_api.base_http_test import BaseTestHttp
class TestHttpReview(BaseTestHttp): class TestHttpReview(BaseTestHttp):
def setUp(self): def setUp(self):
super().setUp([Event, Recordings, ReviewSegment]) super().setUp([Event, Recordings, ReviewSegment, UserReviewStatus])
self.app = super().create_app() self.app = super().create_app()
self.user_id = "admin"
# Mock get_current_user for all tests
async def mock_get_current_user():
return {"username": self.user_id, "role": "admin"}
self.app.dependency_overrides[get_current_user] = mock_get_current_user
def tearDown(self):
self.app.dependency_overrides.clear()
super().tearDown()
def _get_reviews(self, ids: list[str]): def _get_reviews(self, ids: list[str]):
return list( return list(
@ -24,6 +37,13 @@ class TestHttpReview(BaseTestHttp):
Recordings.select(Recordings.id).where(Recordings.id.in_(ids)).execute() Recordings.select(Recordings.id).where(Recordings.id.in_(ids)).execute()
) )
def _insert_user_review_status(self, review_id: str, reviewed: bool = True):
UserReviewStatus.create(
user_id=self.user_id,
review_segment=ReviewSegment.get(ReviewSegment.id == review_id),
has_been_reviewed=reviewed,
)
#################################################################################################################### ####################################################################################################################
################################### GET /review Endpoint ######################################################## ################################### GET /review Endpoint ########################################################
#################################################################################################################### ####################################################################################################################
@ -43,11 +63,14 @@ class TestHttpReview(BaseTestHttp):
now = datetime.now().timestamp() now = datetime.now().timestamp()
with TestClient(self.app) as client: with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random", now - 2, now - 1) id = "123456.random"
super().insert_mock_review_segment(id, now - 2, now - 1)
response = client.get("/review") response = client.get("/review")
assert response.status_code == 200 assert response.status_code == 200
response_json = response.json() response_json = response.json()
assert len(response_json) == 1 assert len(response_json) == 1
assert response_json[0]["id"] == id
assert response_json[0]["has_been_reviewed"] == False
def test_get_review_with_time_filter_no_matches(self): def test_get_review_with_time_filter_no_matches(self):
now = datetime.now().timestamp() now = datetime.now().timestamp()
@ -391,37 +414,27 @@ class TestHttpReview(BaseTestHttp):
with TestClient(self.app) as client: with TestClient(self.app) as client:
five_days_ago_ts = five_days_ago.timestamp() five_days_ago_ts = five_days_ago.timestamp()
for i in range(10): for i in range(10):
id = f"123456_{i}.random_alert_not_reviewed"
super().insert_mock_review_segment( super().insert_mock_review_segment(
f"123456_{i}.random_alert_not_reviewed", id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.alert
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.alert,
False,
) )
for i in range(10): for i in range(10):
id = f"123456_{i}.random_alert_reviewed"
super().insert_mock_review_segment( super().insert_mock_review_segment(
f"123456_{i}.random_alert_reviewed", id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.alert
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.alert,
True,
) )
self._insert_user_review_status(id, reviewed=True)
for i in range(10): for i in range(10):
id = f"123456_{i}.random_detection_not_reviewed"
super().insert_mock_review_segment( super().insert_mock_review_segment(
f"123456_{i}.random_detection_not_reviewed", id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.detection
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.detection,
False,
) )
for i in range(5): for i in range(5):
id = f"123456_{i}.random_detection_reviewed"
super().insert_mock_review_segment( super().insert_mock_review_segment(
f"123456_{i}.random_detection_reviewed", id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.detection
five_days_ago_ts,
five_days_ago_ts,
SeverityEnum.detection,
True,
) )
self._insert_user_review_status(id, reviewed=True)
response = client.get("/review/summary") response = client.get("/review/summary")
assert response.status_code == 200 assert response.status_code == 200
response_json = response.json() response_json = response.json()
@ -447,6 +460,7 @@ class TestHttpReview(BaseTestHttp):
#################################################################################################################### ####################################################################################################################
################################### POST reviews/viewed Endpoint ################################################ ################################### POST reviews/viewed Endpoint ################################################
#################################################################################################################### ####################################################################################################################
def test_post_reviews_viewed_no_body(self): def test_post_reviews_viewed_no_body(self):
with TestClient(self.app) as client: with TestClient(self.app) as client:
super().insert_mock_review_segment("123456.random") super().insert_mock_review_segment("123456.random")
@ -473,12 +487,11 @@ class TestHttpReview(BaseTestHttp):
assert response["success"] == True assert response["success"] == True
assert response["message"] == "Reviewed multiple items" assert response["message"] == "Reviewed multiple items"
# Verify that in DB the review segment was not changed # Verify that in DB the review segment was not changed
review_segment_in_db = ( with self.assertRaises(DoesNotExist):
ReviewSegment.select(ReviewSegment.has_been_reviewed) UserReviewStatus.get(
.where(ReviewSegment.id == id) UserReviewStatus.user_id == self.user_id,
.get() UserReviewStatus.review_segment == "1",
) )
assert review_segment_in_db.has_been_reviewed == False
def test_post_reviews_viewed(self): def test_post_reviews_viewed(self):
with TestClient(self.app) as client: with TestClient(self.app) as client:
@ -487,16 +500,15 @@ class TestHttpReview(BaseTestHttp):
body = {"ids": [id]} body = {"ids": [id]}
response = client.post("/reviews/viewed", json=body) response = client.post("/reviews/viewed", json=body)
assert response.status_code == 200 assert response.status_code == 200
response = response.json() response_json = response.json()
assert response["success"] == True assert response_json["success"] == True
assert response["message"] == "Reviewed multiple items" assert response_json["message"] == "Reviewed multiple items"
# Verify that in DB the review segment was changed # Verify UserReviewStatus was created
review_segment_in_db = ( user_review = UserReviewStatus.get(
ReviewSegment.select(ReviewSegment.has_been_reviewed) UserReviewStatus.user_id == self.user_id,
.where(ReviewSegment.id == id) UserReviewStatus.review_segment == id,
.get()
) )
assert review_segment_in_db.has_been_reviewed == True assert user_review.has_been_reviewed == True
#################################################################################################################### ####################################################################################################################
################################### POST reviews/delete Endpoint ################################################ ################################### POST reviews/delete Endpoint ################################################
@ -672,8 +684,7 @@ class TestHttpReview(BaseTestHttp):
"camera": "front_door", "camera": "front_door",
"start_time": now + 1, "start_time": now + 1,
"end_time": now + 2, "end_time": now + 2,
"has_been_reviewed": False, "severity": "alert",
"severity": SeverityEnum.alert,
"thumb_path": "False", "thumb_path": "False",
"data": {"detections": {"event_id": event_id}}, "data": {"detections": {"event_id": event_id}},
}, },
@ -708,8 +719,7 @@ class TestHttpReview(BaseTestHttp):
"camera": "front_door", "camera": "front_door",
"start_time": now + 1, "start_time": now + 1,
"end_time": now + 2, "end_time": now + 2,
"has_been_reviewed": False, "severity": "alert",
"severity": SeverityEnum.alert,
"thumb_path": "False", "thumb_path": "False",
"data": {}, "data": {},
}, },
@ -719,6 +729,7 @@ class TestHttpReview(BaseTestHttp):
#################################################################################################################### ####################################################################################################################
################################### DELETE /review/{review_id}/viewed Endpoint ################################## ################################### DELETE /review/{review_id}/viewed Endpoint ##################################
#################################################################################################################### ####################################################################################################################
def test_delete_review_viewed_review_not_found(self): def test_delete_review_viewed_review_not_found(self):
with TestClient(self.app) as client: with TestClient(self.app) as client:
review_id = "123456.random" review_id = "123456.random"
@ -735,11 +746,10 @@ class TestHttpReview(BaseTestHttp):
with TestClient(self.app) as client: with TestClient(self.app) as client:
review_id = "123456.review.random" review_id = "123456.review.random"
super().insert_mock_review_segment( super().insert_mock_review_segment(review_id, now + 1, now + 2)
review_id, now + 1, now + 2, has_been_reviewed=True self._insert_user_review_status(review_id, reviewed=True)
) # Verify its reviewed before
review_before = ReviewSegment.get(ReviewSegment.id == review_id) response = client.get(f"/review/{review_id}")
assert review_before.has_been_reviewed == True
response = client.delete(f"/review/{review_id}/viewed") response = client.delete(f"/review/{review_id}/viewed")
assert response.status_code == 200 assert response.status_code == 200
@ -749,5 +759,9 @@ class TestHttpReview(BaseTestHttp):
response_json, response_json,
) )
review_after = ReviewSegment.get(ReviewSegment.id == review_id) # Verify its unreviewed after
assert review_after.has_been_reviewed == False with self.assertRaises(DoesNotExist):
UserReviewStatus.get(
UserReviewStatus.user_id == self.user_id,
UserReviewStatus.review_segment == review_id,
)

View File

@ -172,6 +172,16 @@ class TrackedObjectProcessor(threading.Thread):
retain=True, retain=True,
) )
if obj.obj_data.get("sub_label"):
sub_label = obj.obj_data["sub_label"][0]
if sub_label in self.config.model.all_attribute_logos:
self.dispatcher.publish(
f"{camera}/{sub_label}/snapshot",
jpg_bytes,
retain=True,
)
def camera_activity(camera, activity): def camera_activity(camera, activity):
last_activity = self.camera_activity.get(camera) last_activity = self.camera_activity.get(camera)

View File

@ -4,6 +4,9 @@ import base64
import os import os
from pathlib import Path from pathlib import Path
import cv2
from numpy import ndarray
from frigate.const import CLIPS_DIR, THUMB_DIR from frigate.const import CLIPS_DIR, THUMB_DIR
from frigate.models import Event from frigate.models import Event
@ -21,6 +24,11 @@ def get_event_thumbnail_bytes(event: Event) -> bytes | None:
return None return None
def get_event_snapshot(event: Event) -> ndarray:
media_name = f"{event.camera}-{event.id}"
return cv2.imread(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
### Deletion ### Deletion

View File

@ -113,8 +113,10 @@ def capture_frames(
def get_enabled_state(): def get_enabled_state():
"""Fetch the latest enabled state from ZMQ.""" """Fetch the latest enabled state from ZMQ."""
_, config_data = config_subscriber.check_for_update() _, config_data = config_subscriber.check_for_update()
if config_data: if config_data:
return config_data.enabled config.enabled = config_data.enabled
return config.enabled return config.enabled
while not stop_event.is_set(): while not stop_event.is_set():

View File

@ -0,0 +1,87 @@
"""Peewee migrations -- 030_create_user_review_status.py.
This migration creates the UserReviewStatus table to track per-user review states,
migrates existing has_been_reviewed data from ReviewSegment to all users in the user table,
and drops the has_been_reviewed column. Rollback drops UserReviewStatus and restores the column.
Some examples (model - class or model_name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import peewee as pw
from frigate.models import User, UserReviewStatus
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
User._meta.database = database
UserReviewStatus._meta.database = database
migrator.sql(
"""
CREATE TABLE IF NOT EXISTS "userreviewstatus" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"user_id" VARCHAR(30) NOT NULL,
"review_segment_id" VARCHAR(30) NOT NULL,
"has_been_reviewed" INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY ("review_segment_id") REFERENCES "reviewsegment" ("id") ON DELETE CASCADE
)
"""
)
# Add unique index on (user_id, review_segment_id)
migrator.sql(
'CREATE UNIQUE INDEX IF NOT EXISTS "userreviewstatus_user_segment" ON "userreviewstatus" ("user_id", "review_segment_id")'
)
# Migrate existing has_been_reviewed data to UserReviewStatus for all users
def migrate_data():
all_users = list(User.select())
if not all_users:
return
cursor = database.execute_sql(
'SELECT "id" FROM "reviewsegment" WHERE "has_been_reviewed" = 1'
)
reviewed_segment_ids = [row[0] for row in cursor.fetchall()]
# also migrate for anonymous (unauthenticated users)
usernames = [user.username for user in all_users] + ["anonymous"]
for segment_id in reviewed_segment_ids:
for username in usernames:
UserReviewStatus.create(
user_id=username,
review_segment=segment_id,
has_been_reviewed=True,
)
if not fake: # Only run data migration if not faking
migrator.python(migrate_data)
migrator.sql('ALTER TABLE "reviewsegment" DROP COLUMN "has_been_reviewed"')
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql('DROP TABLE IF EXISTS "userreviewstatus"')
# Restore has_been_reviewed column to reviewsegment (no data restoration)
migrator.sql(
'ALTER TABLE "reviewsegment" ADD COLUMN "has_been_reviewed" INTEGER NOT NULL DEFAULT 0'
)

143
web/package-lock.json generated
View File

@ -41,6 +41,8 @@
"embla-carousel-react": "^8.2.0", "embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4", "framer-motion": "^11.5.4",
"hls.js": "^1.5.20", "hls.js": "^1.5.20",
"i18next": "^24.2.0",
"i18next-http-backend": "^3.0.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"konva": "^9.3.18", "konva": "^9.3.18",
@ -56,6 +58,7 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-grid-layout": "^1.5.0", "react-grid-layout": "^1.5.0",
"react-hook-form": "^7.52.1", "react-hook-form": "^7.52.1",
"react-i18next": "^15.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",
@ -192,9 +195,10 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.24.4", "version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
"integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
"license": "MIT",
"dependencies": { "dependencies": {
"regenerator-runtime": "^0.14.0" "regenerator-runtime": "^0.14.0"
}, },
@ -4306,6 +4310,15 @@
"toggle-selection": "^1.0.6" "toggle-selection": "^1.0.6"
} }
}, },
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -5427,6 +5440,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-proxy-agent": { "node_modules/http-proxy-agent": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@ -5455,6 +5477,46 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/i18next": {
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",
"integrity": "sha512-ArJJTS1lV6lgKH7yEf4EpgNZ7+THl7bsGxxougPYiXRTJ/Fe1j08/TBpV9QsXCIYVfdE/HWG/xLezJ5DOlfBOA==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.1.tgz",
"integrity": "sha512-XT2lYSkbAtDE55c6m7CtKxxrsfuRQO3rUfHzj8ZyRtY9CkIX3aRGwXGTkUhpGWce+J8n7sfu3J0f2wTzo7Lw0A==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/idb-keyval": { "node_modules/idb-keyval": {
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
@ -6359,6 +6421,48 @@
"react-dom": "^16.8 || ^17 || ^18" "react-dom": "^16.8 || ^17 || ^18"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.18", "version": "2.0.18",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
@ -7151,6 +7255,28 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-i18next": {
"version": "15.2.0",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.2.0.tgz",
"integrity": "sha512-iJNc8111EaDtVTVMKigvBtPHyrJV+KblWG73cUxqp+WmJCcwkzhWNFXmkAD5pwP2Z4woeDj/oXDdbjDsb3Gutg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.0",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-icons": { "node_modules/react-icons": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@ -8455,7 +8581,7 @@
"version": "5.8.2", "version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -8881,6 +9007,15 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vscode-jsonrpc": { "node_modules/vscode-jsonrpc": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",

View File

@ -47,6 +47,8 @@
"embla-carousel-react": "^8.2.0", "embla-carousel-react": "^8.2.0",
"framer-motion": "^11.5.4", "framer-motion": "^11.5.4",
"hls.js": "^1.5.20", "hls.js": "^1.5.20",
"i18next": "^24.2.0",
"i18next-http-backend": "^3.0.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"konva": "^9.3.18", "konva": "^9.3.18",
@ -62,6 +64,7 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-grid-layout": "^1.5.0", "react-grid-layout": "^1.5.0",
"react-hook-form": "^7.52.1", "react-hook-form": "^7.52.1",
"react-i18next": "^15.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
"react-router-dom": "^6.26.0", "react-router-dom": "^6.26.0",

View File

@ -0,0 +1,429 @@
{
"speech": "Speech",
"babbling": "Babbling",
"yell": "Yell",
"bellow": "Bellow",
"whoop": "Whoop",
"whispering": "Whispering",
"laughter": "Laughter",
"snicker": "Snicker",
"crying": "Crying",
"sigh": "Sigh",
"singing": "Singing",
"choir": "Choir",
"yodeling": "Yodeling",
"chant": "Chant",
"mantra": "Mantra",
"child_singing": "Child Singing",
"synthetic_singing": "Synthetic Singing",
"rapping": "Rapping",
"humming": "Humming",
"groan": "Groan",
"grunt": "Grunt",
"whistling": "Whistling",
"breathing": "Breathing",
"wheeze": "Wheeze",
"snoring": "Snoring",
"gasp": "Gasp",
"pant": "Pant",
"snort": "Snort",
"cough": "Cough",
"throat_clearing": "Throat Clearing",
"sneeze": "Sneeze",
"sniff": "Sniff",
"run": "Run",
"shuffle": "Shuffle",
"footsteps": "Footsteps",
"chewing": "Chewing",
"biting": "Biting",
"gargling": "Gargling",
"stomach_rumble": "Stomach Rumble",
"burping": "Burping",
"hiccup": "Hiccup",
"fart": "Fart",
"hands": "Hands",
"finger_snapping": "Finger Snapping",
"clapping": "Clapping",
"heartbeat": "Heartbeat",
"heart_murmur": "Heart Murmur",
"cheering": "Cheering",
"applause": "Applause",
"chatter": "Chatter",
"crowd": "Crowd",
"children_playing": "Children Playing",
"animal": "Animal",
"pets": "Pets",
"dog": "Dog",
"bark": "Bark",
"yip": "Yip",
"howl": "Howl",
"bow_wow": "Bow Wow",
"growling": "Growling",
"whimper_dog": "Dog Whimper",
"cat": "Cat",
"purr": "Purr",
"meow": "Meow",
"hiss": "Hiss",
"caterwaul": "Caterwaul",
"livestock": "Livestock",
"horse": "Horse",
"clip_clop": "Clip Clop",
"neigh": "Neigh",
"cattle": "Cattle",
"moo": "Moo",
"cowbell": "Cowbell",
"pig": "Pig",
"oink": "Oink",
"goat": "Goat",
"bleat": "Bleat",
"sheep": "Sheep",
"fowl": "Fowl",
"chicken": "Chicken",
"cluck": "Cluck",
"cock_a_doodle_doo": "Cock-a-Doodle-Doo",
"turkey": "Turkey",
"gobble": "Gobble",
"duck": "Duck",
"quack": "Quack",
"goose": "Goose",
"honk": "Honk",
"wild_animals": "Wild Animals",
"roaring_cats": "Roaring Cats",
"roar": "Roar",
"bird": "Bird",
"chirp": "Chirp",
"squawk": "Squawk",
"pigeon": "Pigeon",
"coo": "Coo",
"crow": "Crow",
"caw": "Caw",
"owl": "Owl",
"hoot": "Hoot",
"flapping_wings": "Flapping Wings",
"dogs": "Dogs",
"rats": "Rats",
"mouse": "Mouse",
"patter": "Patter",
"insect": "Insect",
"cricket": "Cricket",
"mosquito": "Mosquito",
"fly": "Fly",
"buzz": "Buzz",
"frog": "Frog",
"croak": "Croak",
"snake": "Snake",
"rattle": "Rattle",
"whale_vocalization": "Whale Vocalization",
"music": "Music",
"musical_instrument": "Musical Instrument",
"plucked_string_instrument": "Plucked String Instrument",
"guitar": "Guitar",
"electric_guitar": "Electric Guitar",
"bass_guitar": "Bass Guitar",
"acoustic_guitar": "Acoustic Guitar",
"steel_guitar": "Steel Guitar",
"tapping": "Tapping",
"strum": "Strum",
"banjo": "Banjo",
"sitar": "Sitar",
"mandolin": "Mandolin",
"zither": "Zither",
"ukulele": "Ukulele",
"keyboard": "Keyboard",
"piano": "Piano",
"electric_piano": "Electric Piano",
"organ": "Organ",
"electronic_organ": "Electronic Organ",
"hammond_organ": "Hammond Organ",
"synthesizer": "Synthesizer",
"sampler": "Sampler",
"harpsichord": "Harpsichord",
"percussion": "Percussion",
"drum_kit": "Drum Kit",
"drum_machine": "Drum Machine",
"drum": "Drum",
"snare_drum": "Snare Drum",
"rimshot": "Rimshot",
"drum_roll": "Drum Roll",
"bass_drum": "Bass Drum",
"timpani": "Timpani",
"tabla": "Tabla",
"cymbal": "Cymbal",
"hi_hat": "Hi-Hat",
"wood_block": "Wood Block",
"tambourine": "Tambourine",
"maraca": "Maraca",
"gong": "Gong",
"tubular_bells": "Tubular Bells",
"mallet_percussion": "Mallet Percussion",
"marimba": "Marimba",
"glockenspiel": "Glockenspiel",
"vibraphone": "Vibraphone",
"steelpan": "Steelpan",
"orchestra": "Orchestra",
"brass_instrument": "Brass Instrument",
"french_horn": "French Horn",
"trumpet": "Trumpet",
"trombone": "Trombone",
"bowed_string_instrument": "Bowed String Instrument",
"string_section": "String Section",
"violin": "Violin",
"pizzicato": "Pizzicato",
"cello": "Cello",
"double_bass": "Double Bass",
"wind_instrument": "Wind Instrument",
"flute": "Flute",
"saxophone": "Saxophone",
"clarinet": "Clarinet",
"harp": "Harp",
"bell": "Bell",
"church_bell": "Church Bell",
"jingle_bell": "Jingle Bell",
"bicycle_bell": "Bicycle Bell",
"tuning_fork": "Tuning Fork",
"chime": "Chime",
"wind_chime": "Wind Chime",
"harmonica": "Harmonica",
"accordion": "Accordion",
"bagpipes": "Bagpipes",
"didgeridoo": "Didgeridoo",
"theremin": "Theremin",
"singing_bowl": "Singing Bowl",
"scratching": "Scratching",
"pop_music": "Pop Music",
"hip_hop_music": "Hip-Hop Music",
"beatboxing": "Beatboxing",
"rock_music": "Rock Music",
"heavy_metal": "Heavy Metal",
"punk_rock": "Punk Rock",
"grunge": "Grunge",
"progressive_rock": "Progressive Rock",
"rock_and_roll": "Rock and Roll",
"psychedelic_rock": "Psychedelic Rock",
"rhythm_and_blues": "Rhythm and Blues",
"soul_music": "Soul Music",
"reggae": "Reggae",
"country": "Country",
"swing_music": "Swing Music",
"bluegrass": "Bluegrass",
"funk": "Funk",
"folk_music": "Folk Music",
"middle_eastern_music": "Middle Eastern Music",
"jazz": "Jazz",
"disco": "Disco",
"classical_music": "Classical Music",
"opera": "Opera",
"electronic_music": "Electronic Music",
"house_music": "House Music",
"techno": "Techno",
"dubstep": "Dubstep",
"drum_and_bass": "Drum and Bass",
"electronica": "Electronica",
"electronic_dance_music": "Electronic Dance Music",
"ambient_music": "Ambient Music",
"trance_music": "Trance Music",
"music_of_latin_america": "Music of Latin America",
"salsa_music": "Salsa Music",
"flamenco": "Flamenco",
"blues": "Blues",
"music_for_children": "Music for Children",
"new-age_music": "New Age Music",
"vocal_music": "Vocal Music",
"a_capella": "A Capella",
"music_of_africa": "Music of Africa",
"afrobeat": "Afrobeat",
"christian_music": "Christian Music",
"gospel_music": "Gospel Music",
"music_of_asia": "Music of Asia",
"carnatic_music": "Carnatic Music",
"music_of_bollywood": "Music of Bollywood",
"ska": "Ska",
"traditional_music": "Traditional Music",
"independent_music": "Independent Music",
"song": "Song",
"background_music": "Background Music",
"theme_music": "Theme Music",
"jingle": "Jingle",
"soundtrack_music": "Soundtrack Music",
"lullaby": "Lullaby",
"video_game_music": "Video Game Music",
"christmas_music": "Christmas Music",
"dance_music": "Dance Music",
"wedding_music": "Wedding Music",
"happy_music": "Happy Music",
"sad_music": "Sad Music",
"tender_music": "Tender Music",
"exciting_music": "Exciting Music",
"angry_music": "Angry Music",
"scary_music": "Scary Music",
"wind": "Wind",
"rustling_leaves": "Rustling Leaves",
"wind_noise": "Wind Noise",
"thunderstorm": "Thunderstorm",
"thunder": "Thunder",
"water": "Water",
"rain": "Rain",
"raindrop": "Raindrop",
"rain_on_surface": "Rain on Surface",
"stream": "Stream",
"waterfall": "Waterfall",
"ocean": "Ocean",
"waves": "Waves",
"steam": "Steam",
"gurgling": "Gurgling",
"fire": "Fire",
"crackle": "Crackle",
"vehicle": "Vehicle",
"boat": "Boat",
"sailboat": "Sailboat",
"rowboat": "Rowboat",
"motorboat": "Motorboat",
"ship": "Ship",
"motor_vehicle": "Motor Vehicle",
"car": "Car",
"toot": "Toot",
"car_alarm": "Car Alarm",
"power_windows": "Power Windows",
"skidding": "Skidding",
"tire_squeal": "Tire Squeal",
"car_passing_by": "Car Passing By",
"race_car": "Race Car",
"truck": "Truck",
"air_brake": "Air Brake",
"air_horn": "Air Horn",
"reversing_beeps": "Reversing Beeps",
"ice_cream_truck": "Ice Cream Truck",
"bus": "Bus",
"emergency_vehicle": "Emergency Vehicle",
"police_car": "Police Car",
"ambulance": "Ambulance",
"fire_engine": "Fire Engine",
"motorcycle": "Motorcycle",
"traffic_noise": "Traffic Noise",
"rail_transport": "Rail Transport",
"train": "Train",
"train_whistle": "Train Whistle",
"train_horn": "Train Horn",
"railroad_car": "Railroad Car",
"train_wheels_squealing": "Train Wheels Squealing",
"subway": "Subway",
"aircraft": "Aircraft",
"aircraft_engine": "Aircraft Engine",
"jet_engine": "Jet Engine",
"propeller": "Propeller",
"helicopter": "Helicopter",
"fixed-wing_aircraft": "Fixed-Wing Aircraft",
"bicycle": "Bicycle",
"skateboard": "Skateboard",
"engine": "Engine",
"light_engine": "Light Engine",
"dental_drill's_drill": "Dental Drill",
"lawn_mower": "Lawn Mower",
"chainsaw": "Chainsaw",
"medium_engine": "Medium Engine",
"heavy_engine": "Heavy Engine",
"engine_knocking": "Engine Knocking",
"engine_starting": "Engine Starting",
"idling": "Idling",
"accelerating": "Accelerating",
"door": "Door",
"doorbell": "Doorbell",
"ding-dong": "Ding-Dong",
"sliding_door": "Sliding Door",
"slam": "Slam",
"knock": "Knock",
"tap": "Tap",
"squeak": "Squeak",
"cupboard_open_or_close": "Cupboard Open or Close",
"drawer_open_or_close": "Drawer Open or Close",
"dishes": "Dishes",
"cutlery": "Cutlery",
"chopping": "Chopping",
"frying": "Frying",
"microwave_oven": "Microwave Oven",
"blender": "Blender",
"water_tap": "Water Tap",
"sink": "Sink",
"bathtub": "Bathtub",
"hair_dryer": "Hair Dryer",
"toilet_flush": "Toilet Flush",
"toothbrush": "Toothbrush",
"electric_toothbrush": "Electric Toothbrush",
"vacuum_cleaner": "Vacuum Cleaner",
"zipper": "Zipper",
"keys_jangling": "Keys Jangling",
"coin": "Coin",
"scissors": "Scissors",
"electric_shaver": "Electric Shaver",
"shuffling_cards": "Shuffling Cards",
"typing": "Typing",
"typewriter": "Typewriter",
"computer_keyboard": "Computer Keyboard",
"writing": "Writing",
"alarm": "Alarm",
"telephone": "Telephone",
"telephone_bell_ringing": "Telephone Bell Ringing",
"ringtone": "Ringtone",
"telephone_dialing": "Telephone Dialing",
"dial_tone": "Dial Tone",
"busy_signal": "Busy Signal",
"alarm_clock": "Alarm Clock",
"siren": "Siren",
"civil_defense_siren": "Civil Defense Siren",
"buzzer": "Buzzer",
"smoke_detector": "Smoke Detector",
"fire_alarm": "Fire Alarm",
"foghorn": "Foghorn",
"whistle": "Whistle",
"steam_whistle": "Steam Whistle",
"mechanisms": "Mechanisms",
"ratchet": "Ratchet",
"clock": "Clock",
"tick": "Tick",
"tick-tock": "Tick-Tock",
"gears": "Gears",
"pulleys": "Pulleys",
"sewing_machine": "Sewing Machine",
"mechanical_fan": "Mechanical Fan",
"air_conditioning": "Air Conditioning",
"cash_register": "Cash Register",
"printer": "Printer",
"camera": "Camera",
"single-lens_reflex_camera": "Single-Lens Reflex Camera",
"tools": "Tools",
"hammer": "Hammer",
"jackhammer": "Jackhammer",
"sawing": "Sawing",
"filing": "Filing",
"sanding": "Sanding",
"power_tool": "Power Tool",
"drill": "Drill",
"explosion": "Explosion",
"gunshot": "Gunshot",
"machine_gun": "Machine Gun",
"fusillade": "Fusillade",
"artillery_fire": "Artillery Fire",
"cap_gun": "Cap Gun",
"fireworks": "Fireworks",
"firecracker": "Firecracker",
"burst": "Burst",
"eruption": "Eruption",
"boom": "Boom",
"wood": "Wood",
"chop": "Chop",
"splinter": "Splinter",
"crack": "Crack",
"glass": "Glass",
"chink": "Chink",
"shatter": "Shatter",
"silence": "Silence",
"sound_effect": "Sound Effect",
"environmental_noise": "Environmental Noise",
"static": "Static",
"white_noise": "White Noise",
"pink_noise": "Pink Noise",
"television": "Television",
"radio": "Radio",
"field_recording": "Field Recording",
"scream": "Scream"
}

View File

@ -0,0 +1,203 @@
{
"time": {
"untilForTime": "Until {{time}}",
"untilForRestart": "Until Frigate restarts.",
"untilRestart": "Until restart",
"ago": "{{timeAgo}} ago",
"justNow": "Just now",
"today": "Today",
"yesterday": "Yesterday",
"last7": "Last 7 days",
"last14": "Last 14 days",
"last30": "Last 30 days",
"thisWeek": "This Week",
"lastWeek": "Last Week",
"thisMonth": "This Month",
"lastMonth": "Last Month",
"5minutes": "5 minutes",
"10minutes": "10 minutes",
"30minutes": "30 minutes",
"1hour": "1 hour",
"12hours": "12 hours",
"24hours": "24 hours",
"pm": "pm",
"am": "am",
"yr": "{{time}}yr",
"year": "{{time}} years",
"mo": "{{time}}mo",
"month": "{{time}} months",
"d": "{{time}}d",
"day": "{{time}} days",
"h": "{{time}}h",
"hour": "{{time}} hours",
"m": "{{time}}m",
"minute": "{{time}} minutes",
"s": "{{time}}s",
"second": "{{time}} seconds",
"formattedTimestamp": {
"12hour": "%b %-d, %I:%M:%S %p",
"24hour": "%b %-d, %H:%M:%S"
},
"formattedTimestamp2": {
"12hour": "%m/%d %I:%M:%S%P",
"24hour": "%d %b %H:%M:%S"
},
"formattedTimestampExcludeSeconds": {
"12hour": "%b %-d, %I:%M %p",
"24hour": "%b %-d, %H:%M"
},
"formattedTimestampWithYear": {
"12hour": "%b %-d %Y, %I:%M %p",
"24hour": "%b %-d %Y, %H:%M"
},
"formattedTimestampOnlyMonthAndDay": "%b %-d"
},
"unit": {
"speed": {
"mph": "mph",
"kph": "kph"
}
},
"label": {
"back": "Go back"
},
"button": {
"apply": "Apply",
"reset": "Reset",
"done": "Done",
"enabled": "Enabled",
"enable": "Enable",
"disabled": "Disabled",
"disable": "Disable",
"save": "Save",
"saving": "Saving...",
"cancel": "Cancel",
"close": "Close",
"copy": "Copy",
"back": "Back",
"history": "History",
"fullscreen": "Fullscreen",
"exitFullscreen": "Exit Fullscreen",
"pictureInPicture": "Picture in Picture",
"twoWayTalk": "Two Way Talk",
"cameraAudio": "Camera Audio",
"on": "ON",
"off": "OFF",
"edit": "Edit",
"copyCoordinates": "Copy coordinates",
"delete": "Delete",
"yes": "Yes",
"no": "No",
"download": "Download",
"info": "Info",
"suspended": "Suspended",
"unsuspended": "Unsuspend",
"play": "Play",
"unselect": "Unselect",
"export": "Export",
"deleteNow": "Delete Now",
"next": "Next"
},
"menu": {
"system": "System",
"systemMetrics": "System metrics",
"configuration": "Configuration",
"systemLogs": "System logs",
"settings": "Settings",
"configurationEditor": "Configuration Editor",
"languages": "Languages",
"language": {
"en": "English",
"zhCN": "简体中文 (Simplified Chinese)",
"withSystem": {
"label": "Use the system settings for language"
}
},
"appearance": "Appearance",
"darkMode": {
"label": "Dark Mode",
"light": "Light",
"dark": "Dark",
"withSystem": {
"label": "Use the system settings for light or dark mode"
}
},
"withSystem": "System",
"theme": {
"label": "Theme",
"blue": "Blue",
"green": "Green",
"nord": "Nord",
"red": "Red",
"contrast": "High Contrast",
"default": "Default"
},
"help": "Help",
"documentation": {
"title": "Documentation",
"label": "Frigate documentation"
},
"restart": "Restart Frigate",
"live": {
"title": "Live",
"allCameras": "All Cameras",
"cameras": {
"title": "Cameras",
"count_one": "{{count}} Camera",
"count_other": "{{count}} Cameras"
}
},
"review": "Review",
"explore": "Explore",
"export": "Export",
"uiPlayground": "UI Playground",
"faceLibrary": "Face Library",
"user": {
"title": "User",
"account": "Account",
"current": "Current User: {{user}}",
"anonymous": "anonymous",
"logout": "Logout",
"setPassword": "Set Password"
}
},
"toast": {
"copyUrlToClipboard": "Copied URL to clipboard.",
"save": {
"title": "Save",
"error": {
"title": "Failed to save config changes: {{errorMessage}}",
"noMessage": "Failed to save config changes"
}
}
},
"role": {
"title": "Role",
"admin": "Admin",
"viewer": "Viewer",
"desc": "Admins have full access to all features in the Frigate UI. Viewers are limited to viewing cameras, review items, and historical footage in the UI."
},
"pagination": {
"label": "pagination",
"previous": {
"title": "Previous",
"label": "Go to previous page"
},
"next": {
"title": "Next",
"label": "Go to next page"
},
"more": "More pages"
},
"accessDenied": {
"documentTitle": "Access Denied - Frigate",
"title": "Access Denied",
"desc": "You don't have permission to view this page."
},
"notFound": {
"documentTitle": "Not Found - Frigate",
"title": "404",
"desc": "Page not found"
},
"selectItem": "Select {{item}}"
}

View File

@ -0,0 +1,15 @@
{
"form": {
"user": "Username",
"password": "Password",
"login": "Login",
"errors": {
"usernameRequired": "Username is required",
"passwordRequired": "Password is required",
"rateLimit": "Exceeded rate limit. Try again later.",
"loginFailed": "Login failed",
"unknownError": "Unknown error. Check logs.",
"webUnknownError": "Unknown error. Check console logs."
}
}
}

View File

@ -0,0 +1,83 @@
{
"group": {
"label": "Camera Groups",
"add": "Add Camera Group",
"edit": "Edit Camera Group",
"delete": {
"label": "Delete Camera Group",
"confirm": {
"title": "Confirm Delete",
"desc": "Are you sure you want to delete the camera group <em>{{name}}</em>?"
}
},
"name": {
"label": "Name",
"placeholder": "Enter a name...",
"errorMessage": {
"mustLeastCharacters": "Camera group name must be at least 2 characters.",
"exists": "Camera group name already exists.",
"nameMustNotPeriod": "Camera group name must not contain a period.",
"invalid": "Invalid camera group name."
}
},
"cameras": {
"label": "Cameras",
"desc": "Select cameras for this group."
},
"icon": "Icon",
"success": "Camera group ({{name}}) has been saved.",
"camera": {
"setting": {
"label": "Camera Streaming Settings",
"title": "{{cameraName}} Streaming Settings",
"desc": "Change the live streaming options for this camera group's dashboard. <em>These settings are device/browser-specific.</em>",
"audioIsAvailable": "Audio is available for this stream",
"audioIsUnavailable": "Audio is unavailable for this stream",
"audio": {
"tips": {
"title": "Audio must be output from your camera and configured in go2rtc for this stream.",
"document": "Read the documentation "
}
},
"streamMethod": {
"label": "Streaming Method",
"method": {
"noStreaming": {
"label": "No Streaming",
"desc": "Camera images will only update once per minute and no live streaming will occur."
},
"smartStreaming": {
"label": "Smart Streaming (recommended)",
"desc": "Smart streaming will update your camera image once per minute when no detectable activity is occurring to conserve bandwidth and resources. When activity is detected, the image seamlessly switches to a live stream."
},
"continuousStreaming": {
"label": "Continuous Streaming",
"desc": {
"title": "Camera image will always be a live stream when visible on the dashboard, even if no activity is being detected.",
"warning": "Continuous streaming may cause high bandwidth usage and performance issues. Use with caution."
}
}
}
},
"compatibilityMode": {
"label": "Compatibility mode",
"desc": "Enable this option only if your camera's live stream is displaying color artifacts and has a diagonal line on the right side of the image."
}
}
}
},
"debug": {
"options": {
"label": "Settings",
"title": "Options",
"showOptions": "Show Options",
"hideOptions": "Hide Options"
},
"boundingBox": "Bounding Box",
"timestamp": "Timestamp",
"zones": "Zones",
"mask": "Mask",
"motion": "Motion",
"regions": "Regions"
}
}

View File

@ -0,0 +1,113 @@
{
"restart": {
"title": "Are you sure you want to restart Frigate?",
"button": "Restart",
"restarting": {
"title": "Frigate is Restarting",
"content": "This page will reload in {{countdown}} seconds.",
"button": "Force Reload Now"
}
},
"explore": {
"plus": {
"submitToPlus": {
"label": "Submit To Frigate+",
"desc": "Objects in locations you want to avoid are not false positives. Submitting them as false positives will confuse the model."
},
"review": {
"true": {
"label": "Confirm this label for Frigate Plus",
"true_one": "This is a {{label}}",
"true_other": "This is an {{label}}"
},
"false": {
"label": "Do not confirm this label for Frigate Plus",
"false_one": "This is not a {{label}}",
"false_other": "This is not an {{label}}"
},
"state": {
"submitted": "Submitted"
}
}
},
"video": {
"viewInHistory": "View in History"
}
},
"export": {
"time": {
"fromTimeline": "Select from Timeline",
"lastHour_one": "Last Hour",
"lastHour_other": "Last {{count}} Hours",
"custom": "Custom",
"start": {
"title": "Start Time",
"label": "Select Start Time"
},
"end": {
"title": "End Time",
"label": "Select End Time"
}
},
"name": {
"placeholder": "Name the Export"
},
"select": "Select",
"export": "Export",
"selectOrExport": "Select or Export",
"toast": {
"success": "Successfully started export. View the file in the /exports folder.",
"error": {
"failed": "Failed to start export: {{error}}",
"endTimeMustAfterStartTime": "End time must be after start time",
"noVaildTimeSelected": "No valid time range selected"
}
},
"fromTimeline": {
"saveExport": "Save Export",
"previewExport": "Preview Export"
}
},
"streaming": {
"label": "Stream",
"restreaming": {
"disabled": "Restreaming is not enabled for this camera.",
"desc": {
"title": "Set up go2rtc for additional live view options and audio for this camera.",
"readTheDocumentation": "Read the documentation "
}
},
"showStats": {
"label": "Show stream stats",
"desc": "Enable this option to show stream statistics as an overlay on the camera feed."
},
"debugView": "Debug View"
},
"search": {
"saveSearch": {
"label": "Save Search",
"desc": "Provide a name for this saved search.",
"placeholder": "Enter a name for your search",
"overwrite": "{{searchName}} already exists. Saving will overwrite the existing value.",
"success": "Search ({{searchName}}) has been saved.",
"button": {
"save": {
"label": "Save this search"
}
}
}
},
"recording": {
"confirmDelete": {
"title": "Confirm Delete",
"desc": {
"selected": "Are you sure you want to delete all recorded video associated with this review item?<br /><br />Hold the <em>Shift</em> key to bypass this dialog in the future."
}
},
"button": {
"export": "Export",
"markAsReviewed": "Mark as reviewed",
"deleteNow": "Delete Now"
}
}
}

View File

@ -0,0 +1,124 @@
{
"filter": "Filter",
"labels": {
"label": "Labels",
"all": {
"title": "All Labels",
"short": "Labels"
},
"count": "{{count}} Labels"
},
"zones": {
"label": "Zones",
"all": {
"title": "All Zones",
"short": "Zones"
}
},
"dates": {
"all": {
"title": "All Dates",
"short": "Dates"
}
},
"more": "More Filters",
"reset": {
"label": "Reset filters to default values"
},
"timeRange": "Time Range",
"subLabels": {
"label": "Sub Labels",
"all": "All Sub Labels"
},
"score": "Score",
"estimatedSpeed": "Estimated Speed ({{unit}})",
"features": {
"label": "Features",
"hasSnapshot": "Has a snapshot",
"hasVideoClip": "Has a video clip",
"submittedToFrigatePlus": {
"label": "Submitted to Frigate+",
"tips": "You must first filter on tracked objects that have a snapshot.<br /><br />Tracked objects without a snapshot cannot be submitted to Frigate+."
}
},
"sort": {
"label": "Sort",
"dateAsc": "Date (Ascending)",
"dateDesc": "Date (Descending)",
"scoreAsc": "Object Score (Ascending)",
"scoreDesc": "Object Score (Descending)",
"speedAsc": "Estimated Speed (Ascending)",
"speedDesc": "Estimated Speed (Descending)",
"relevance": "Relevance"
},
"cameras": {
"label": "Cameras Filter",
"all": {
"title": "All Cameras",
"short": "Cameras"
}
},
"review": {
"showReviewed": "Show Reviewed"
},
"motion": {
"showMotionOnly": "Show Motion Only"
},
"explore": {
"settings": {
"title": "Settings",
"defaultView": {
"title": "Default View",
"desc": "When no filters are selected, display a summary of the most recent tracked objects per label, or display an unfiltered grid.",
"summary": "Summary",
"unfilteredGrid": "Unfiltered Grid"
},
"gridColumns": {
"title": "Grid Columns",
"desc": "Select the number of columns in the grid view."
},
"searchSource": {
"label": "Search Source",
"desc": "Choose whether to search the thumbnails or descriptions of your tracked objects.",
"options": {
"thumbnailImage": "Thumbnail Image",
"description": "Description"
}
}
},
"date": {
"selectDateBy": {
"label": "Select a date to filter by"
}
}
},
"logSettings": {
"label": "Filter log level",
"filterBySeverity": "Filter logs by severity",
"loading": {
"title": "Loading",
"desc": "When the log pane is scrolled to the bottom, new logs automatically stream as they are added."
},
"disableLogStreaming": "Disable log streaming",
"allLogs": "All logs"
},
"trackedObjectDelete": {
"title": "Confirm Delete",
"desc": "Deleting these {{objectLength}} tracked objects removes the snapshot, any saved embeddings, and any associated object lifecycle entries. Recorded footage of these tracked objects in History view will <em>NOT</em> be deleted.<br /><br />Are you sure you want to proceed?<br /><br />Hold the <em>Shift</em> key to bypass this dialog in the future.",
"toast": {
"success": "Tracked objects deleted successfully.",
"error": "Failed to delete tracked objects: {{errorMessage}}"
}
},
"zoneMask": {
"filterBy": "Filter by zone mask"
},
"recognizedLicensePlates": {
"title": "Recognized License Plates",
"loadFailed": "Failed to load recognized license plates.",
"loading": "Loading recognized license plates...",
"placeholder": "Type to search license plates...",
"noLicensePlatesFound": "No license plates found.",
"selectPlatesFromList": "Select one or more plates from the list."
}
}

View File

@ -0,0 +1,8 @@
{
"iconPicker": {
"selectIcon": "Select an icon",
"search": {
"placeholder": "Search for an icon..."
}
}
}

View File

@ -0,0 +1,10 @@
{
"button": {
"downloadVideo": {
"label": "Download Video",
"toast": {
"success": "Your review item video has started downloading."
}
}
}
}

View File

@ -0,0 +1,51 @@
{
"noRecordingsFoundForThisTime": "No recordings found for this time",
"noPreviewFound": "No Preview Found",
"noPreviewFoundFor": "No Preview Found for {{cameraName}}",
"submitFrigatePlus": {
"title": "Submit this frame to Frigate+?",
"submit": "Submit"
},
"livePlayerRequiredIOSVersion": "iOS 17.1 or greater is required for this live stream type.",
"streamOffline": {
"title": "Stream Offline",
"desc": "No frames have been received on the {{cameraName}} <code>detect</code> stream, check error logs"
},
"cameraDisabled": "Camera is disabled",
"stats": {
"streamType": {
"title": "Stream Type:",
"short": "Type"
},
"bandwidth": {
"title": "Bandwidth:",
"short": "Bandwidth"
},
"latency": {
"title": "Latency:",
"value": "{{seconds}} seconds",
"short": {
"title": "Latency",
"value": "{{seconds}} sec"
}
},
"totalFrames": "Total Frames:",
"droppedFrames": {
"title": "Dropped Frames:",
"short": {
"title": "Dropped",
"value": "{{droppedFrames}} frames"
}
},
"decodedFrames": "Decoded Frames:",
"droppedFrameRate": "Dropped Frame Rate:"
},
"toast": {
"success": {
"submittedFrigatePlus": "Successfully submitted frame to Frigate+"
},
"error": {
"submitFrigatePlusFailed": "Failed to submit frame to Frigate+"
}
}
}

View File

@ -0,0 +1,120 @@
{
"person": "Person",
"bicycle": "Bicycle",
"car": "Car",
"motorcycle": "Motorcycle",
"airplane": "Airplane",
"bus": "Bus",
"train": "Train",
"boat": "Boat",
"traffic_light": "Traffic Light",
"fire_hydrant": "Fire Hydrant",
"street_sign": "Street Sign",
"stop_sign": "Stop Sign",
"parking_meter": "Parking Meter",
"bench": "Bench",
"bird": "Bird",
"cat": "Cat",
"dog": "Dog",
"horse": "Horse",
"sheep": "Sheep",
"cow": "Cow",
"elephant": "Elephant",
"bear": "Bear",
"zebra": "Zebra",
"giraffe": "Giraffe",
"hat": "Hat",
"backpack": "Backpack",
"umbrella": "Umbrella",
"shoe": "Shoe",
"eye_glasses": "Eye Glasses",
"handbag": "Handbag",
"tie": "Tie",
"suitcase": "Suitcase",
"frisbee": "Frisbee",
"skis": "Skis",
"snowboard": "Snowboard",
"sports_ball": "Sports Ball",
"kite": "Kite",
"baseball_bat": "Baseball Bat",
"baseball_glove": "Baseball Glove",
"skateboard": "Skateboard",
"surfboard": "Surfboard",
"tennis_racket": "Tennis Racket",
"bottle": "Bottle",
"plate": "Plate",
"wine_glass": "Wine Glass",
"cup": "Cup",
"fork": "Fork",
"knife": "Knife",
"spoon": "Spoon",
"bowl": "Bowl",
"banana": "Banana",
"apple": "Apple",
"sandwich": "Sandwich",
"orange": "Orange",
"broccoli": "Broccoli",
"carrot": "Carrot",
"hot_dog": "Hot Dog",
"pizza": "Pizza",
"donut": "Donut",
"cake": "Cake",
"chair": "Chair",
"couch": "Couch",
"potted_plant": "Potted Plant",
"bed": "Bed",
"mirror": "Mirror",
"dining_table": "Dining Table",
"window": "Window",
"desk": "Desk",
"toilet": "Toilet",
"door": "Door",
"tv": "TV",
"laptop": "Laptop",
"mouse": "Mouse",
"remote": "Remote",
"keyboard": "Keyboard",
"cell_phone": "Cell Phone",
"microwave": "Microwave",
"oven": "Oven",
"toaster": "Toaster",
"sink": "Sink",
"refrigerator": "Refrigerator",
"blender": "Blender",
"book": "Book",
"clock": "Clock",
"vase": "Vase",
"scissors": "Scissors",
"teddy_bear": "Teddy Bear",
"hair_dryer": "Hair Dryer",
"toothbrush": "Toothbrush",
"hair_brush": "Hair Brush",
"vehicle": "Vehicle",
"squirrel": "Squirrel",
"deer": "Deer",
"animal": "Animal",
"bark": "Bark",
"fox": "Fox",
"goat": "Goat",
"rabbit": "Rabbit",
"raccoon": "Raccoon",
"robot_lawnmower": "Robot Lawnmower",
"waste_bin": "Waste Bin",
"on_demand": "On Demand",
"face": "Face",
"license_plate": "License Plate",
"package": "Package",
"bbq_grill": "BBQ Grill",
"amazon": "Amazon",
"usps": "USPS",
"ups": "UPS",
"fedex": "FedEx",
"dhl": "DHL",
"an_post": "An Post",
"purolator": "Purolator",
"postnl": "PostNL",
"nzpost": "NZPost",
"postnord": "PostNord",
"gls": "GLS",
"dpd": "DPD"
}

View File

@ -0,0 +1,16 @@
{
"documentTitle": "Config Editor - Frigate",
"configEditor": "Config Editor",
"copyConfig": "Copy Config",
"saveAndRestart": "Save & Restart",
"saveOnly": "Save Only",
"toast": {
"success": {
"copyToClipboard": "Config copied to clipboard."
},
"error": {
"savingError": "Error saving config"
}
}
}

View File

@ -0,0 +1,35 @@
{
"alerts": "Alerts",
"detections": "Detections",
"motion": {
"label": "Motion",
"only": "Motion only"
},
"allCameras": "All Cameras",
"empty": {
"alert": "There are no alerts to review",
"detection": "There are no detections to review",
"motion": "No motion data found"
},
"timeline": "Timeline",
"timeline.aria": "Select timeline",
"events": {
"label": "Events",
"aria": "Select events",
"noFoundForTimePeriod": "No events found for this time period."
},
"documentTitle": "Review - Frigate",
"recordings": {
"documentTitle": "Recordings - Frigate"
},
"calendarFilter": {
"last24Hours": "Last 24 Hours"
},
"markAsReviewed": "Mark as Reviewed",
"markTheseItemsAsReviewed": "Mark these items as reviewed",
"newReviewItems": {
"label": "View new review items",
"button": "New Items To Review"
},
"camera": "Camera"
}

View File

@ -0,0 +1,183 @@
{
"documentTitle": "Explore - Frigate",
"generativeAI": "Generative AI",
"exploreIsUnavailable": {
"title": "Explore is Unavailable",
"embeddingsReindexing": {
"context": "Explore can be used after tracked object embeddings have finished reindexing.",
"startingUp": "Starting up...",
"estimatedTime": "Estimated time remaining:",
"finishingShortly": "Finishing shortly",
"step": {
"thumbnailsEmbedded": "Thumbnails embedded: ",
"descriptionsEmbedded": "Descriptions embedded: ",
"trackedObjectsProcessed": "Tracked objects processed: "
}
},
"downloadingModels": {
"context": "Frigate is downloading the necessary embeddings models to support the Semantic Search feature. This may take several minutes depending on the speed of your network connection.",
"setup": {
"visionModel": "Vision model",
"visionModelFeatureExtractor": "Vision model feature extractor",
"textModel": "Text model",
"textTokenizer": "Text tokenizer"
},
"tips": {
"context": "You may want to reindex the embeddings of your tracked objects once the models are downloaded.",
"documentation": "Read the documentation"
},
"error": "An error has occurred. Check Frigate logs."
}
},
"trackedObjectDetails": "Tracked Object Details",
"type": {
"details": "details",
"snapshot": "snapshot",
"video": "video",
"object_lifecycle": "object lifecycle"
},
"objectLifecycle": {
"title": "Object Lifecycle",
"noImageFound": "No image found for this timestamp.",
"createObjectMask": "Create Object Mask",
"adjustAnnotationSettings": "Adjust annotation settings",
"scrollViewTips": "Scroll to view the significant moments of this object's lifecycle.",
"autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
"lifecycleItemDesc": {
"visible": "{{label}} detected",
"entered_zone": "{{label}} entered {{zones}}",
"active": "{{label}} became active",
"stationary": "{{label}} became stationary",
"attribute": {
"faceOrLicense_plate": "{{attribute}} detected for {{label}}",
"other": "{{label}} recognized as {{attribute}}"
},
"gone": "{{label}} left",
"heard": "{{label}} heard",
"external": "{{label}} detected"
},
"annotationSettings": {
"title": "Annotation Settings",
"showAllZones": {
"title": "Show All Zones",
"desc": "Always show zones on frames where objects have entered a zone."
},
"offset": {
"label": "Annotation Offset",
"desc": "This data comes from your camera's detect feed but is overlayed on images from the the record feed. It is unlikely that the two streams are perfectly in sync. As a result, the bounding box and the footage will not line up perfectly. However, the <code>annotation_offset</code> field can be used to adjust this.",
"documentation": "Read the documentation ",
"millisecondsToOffset": "Milliseconds to offset detect annotations by. <em>Default: 0</em>",
"tips": "TIP: Imagine there is an event clip with a person walking from left to right. If the event timeline bounding box is consistently to the left of the person then the value should be decreased. Similarly, if a person is walking from left to right and the bounding box is consistently ahead of the person then the value should be increased."
}
},
"carousel": {
"previous": "Previous slide",
"next": "Next slide"
}
},
"details": {
"item": {
"title": "Review Item Details",
"desc": "Review item details",
"button": {
"share": "Share this review item",
"viewInExplore": "View in Explore"
},
"tips": {
"mismatch_one": "{{count}} unavailable object was detected and included in this review item. Those objects either did not qualify as an alert or detection or have already been cleaned up/deleted.",
"mismatch_other": "{{count}} unavailable objects were detected and included in this review item. Those objects either did not qualify as an alert or detection or have already been cleaned up/deleted.",
"hasMissingObjects": "Adjust your configuration if you want Frigate to save tracked objects for the following labels: <em>{{objects}}</em>"
},
"toast": {
"success": {
"regenerate": "A new description has been requested from {{provider}}. Depending on the speed of your provider, the new description may take some time to regenerate.",
"updatedSublabel": "Successfully updated sub label."
},
"error": {
"regenerate": "Failed to call {{provider}} for a new description: {{errorMessage}}",
"updatedSublabelFailed": "Failed to update sub label: {{errorMessage}}"
}
}
},
"label": "Label",
"editSubLabel": {
"title": "Edit sub label",
"desc": "Enter a new sub label for this {{label}}",
"descNoLabel": "Enter a new sub label for this tracked object"
},
"topScore": {
"label": "Top Score",
"info": "The top score is the highest median score for the tracked object, so this may differ from the score shown on the search result thumbnail."
},
"estimatedSpeed": "Estimated Speed",
"objects": "Objects",
"camera": "Camera",
"zones": "Zones",
"timestamp": "Timestamp",
"button": {
"findSimilar": "Find Similar",
"regenerate": {
"title": "Regenerate",
"label": "Regenerate tracked object description"
}
},
"description": {
"label": "Description",
"placeholder": "Description of the tracked object",
"aiTips": "Frigate will not request a description from your Generative AI provider until the tracked object's lifecycle has ended."
},
"expandRegenerationMenu": "Expand regeneration menu",
"regenerateFromSnapshot": "Regenerate from Snapshot",
"regenerateFromThumbnails": "Regenerate from Thumbnails",
"tips": {
"descriptionSaved": "Successfully saved description",
"saveDescriptionFailed": "Failed to update the description: {{errorMessage}}"
}
},
"itemMenu": {
"downloadVideo": {
"label": "Download video",
"aria": "Download video"
},
"downloadSnapshot": {
"label": "Download snapshot",
"aria": "Download snapshot"
},
"viewObjectLifecycle": {
"label": "View object lifecycle",
"aria": "Show the object lifecycle"
},
"findSimilar": {
"label": "Find similar",
"aria": "Find similar tracked objects"
},
"submitToPlus": {
"label": "Submit to Frigate+",
"aria": "Submit to Frigate Plus"
},
"viewInHistory": {
"label": "View in History",
"aria": "View in History"
},
"deleteTrackedObject": {
"label": "Delete this tracked object"
}
},
"dialog": {
"confirmDelete": {
"title": "Confirm Delete",
"desc": "Deleting this tracked object removes the snapshot, any saved embeddings, and any associated object lifecycle entries. Recorded footage of this tracked object in History view will <em>NOT</em> be deleted.<br /><br />Are you sure you want to proceed?"
}
},
"noTrackedObjects": "No Tracked Objects Found",
"fetchingTrackedObjectsFailed": "Error fetching tracked objects: {{errorMessage}}",
"trackedObjectsCount": "{{count}} tracked objects ",
"searchResult": {
"deleteTrackedObject": {
"toast": {
"success": "Tracked object deleted successfully.",
"error": "Failed to delete tracked object: {{errorMessage}}"
}
}
}
}

View File

@ -0,0 +1,18 @@
{
"documentTitle": "Export - Frigate",
"search": "Search",
"noExports": "No exports found",
"deleteExport": "Delete Export",
"deleteExport.desc": "Are you sure you want to delete {{exportName}}?",
"editExport": {
"title": "Rename Export",
"desc": "Enter a new name for this export.",
"saveExport": "Save Export"
},
"toast": {
"error": {
"renameExportFailed": "Failed to rename export: {{errorMessage}}"
}
}
}

View File

@ -0,0 +1,52 @@
{
"description": {
"addFace": "Walk through adding a new face to the Face Library."
},
"details": {
"person": "Person",
"confidence": "Confidence",
"face": "Face Details",
"faceDesc": "Details for the face and associated object",
"timestamp": "Timestamp"
},
"documentTitle": "Face Library - Frigate",
"uploadFaceImage": {
"title": "Upload Face Image",
"desc": "Upload an image to scan for faces and include for {{pageToggle}}"
},
"createFaceLibrary": {
"title": "Create Face Library",
"desc": "Create a new face library",
"nextSteps": "It is recommended to use the Train tab to select and train images for each person as they are detected. When building a strong foundation it is strongly recommended to only train on images that are straight-on. Ignore images from cameras that recognize faces from an angle."
},
"train": {
"title": "Train",
"aria": "Select train"
},
"selectItem": "Select {{item}}",
"button": {
"deleteFaceAttempts": "Delete Face Attempts",
"addFace": "Add Face",
"uploadImage": "Upload Image",
"reprocessFace": "Reprocess Face"
},
"readTheDocs": "Read the documentation to view more details on refining images for the Face Library",
"trainFaceAs": "Train Face as:",
"trainFace": "Train Face",
"toast": {
"success": {
"uploadedImage": "Successfully uploaded image.",
"addFaceLibrary": "{{name}} has successfully been added to the Face Library!",
"deletedFace": "Successfully deleted face.",
"trainedFace": "Successfully trained face.",
"updatedFaceScore": "Successfully updated face score."
},
"error": {
"uploadingImageFailed": "Failed to upload image: {{errorMessage}}",
"addFaceLibraryFailed": "Failed to set face name: {{errorMessage}}",
"deleteFaceFailed": "Failed to delete: {{errorMessage}}",
"trainFailed": "Failed to train: {{errorMessage}}",
"updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}"
}
}
}

View File

@ -0,0 +1,158 @@
{
"documentTitle": "Live - Frigate",
"documentTitle.withCamera": "{{camera}} - Live - Frigate",
"lowBandwidthMode": "Low-bandwidth Mode",
"twoWayTalk": {
"enable": "Enable Two Way Talk",
"disable": "Disable Two Way Talk"
},
"cameraAudio": {
"enable": "Enable Camera Audio",
"disable": "Disable Camera Audio"
},
"ptz": {
"move": {
"clickMove": {
"label": "Click in the frame to center the camera",
"enable": "Enable click to move",
"disable": "Disable click to move"
},
"left": {
"label": "Move PTZ camera to the left"
},
"up": {
"label": "Move PTZ camera up"
},
"down": {
"label": "Move PTZ camera down"
},
"right": {
"label": "Move PTZ camera to the right"
}
},
"zoom": {
"in": {
"label": "Zoom PTZ camera in"
},
"out": {
"label": "Zoom PTZ camera out"
}
},
"frame": {
"center": {
"label": "Click in the frame to center the PTZ camera"
}
},
"presets": "PTZ camera presets"
},
"camera": {
"enable": "Enable Camera",
"disable": "Disable Camera"
},
"muteCameras": {
"enable": "Mute All Cameras",
"disable": "Unmute All Cameras"
},
"detect": {
"enable": "Enable Detect",
"disable": "Disable Detect"
},
"recording": {
"enable": "Enable Recording",
"disable": "Disable Recording"
},
"snapshots": {
"enable": "Enable Snapshots",
"disable": "Disable Snapshots"
},
"audioDetect": {
"enable": "Enable Audio Detect",
"disable": "Disable Audio Detect"
},
"autotracking": {
"enable": "Enable Autotracking",
"disable": "Disable Autotracking"
},
"streamStats": {
"enable": "Show Stream Stats",
"disable": "Hide Stream Stats"
},
"manualRecording": {
"title": "On-Demand Recording",
"tips": "Start a manual event based on this camera's recording retention settings.",
"playInBackground": {
"label": "Play in background",
"desc": "Enable this option to continue streaming when the player is hidden."
},
"showStats": {
"label": "Show Stats",
"desc": "Enable this option to show stream statistics as an overlay on the camera feed."
},
"debugView": "Debug View",
"start": "Start on-demand recording",
"started": "Started manual on-demand recording.",
"failedToStart": "Failed to start manual on-demand recording.",
"recordDisabledTips": "Since recording is disabled or restricted in the config for this camera, only a snapshot will be saved.",
"end": "End on-demand recording",
"ended": "Ended manual on-demand recording.",
"failedToEnd": "Failed to end manual on-demand recording."
},
"streamingSettings": "Streaming Settings",
"notifications": "Notifications",
"audio": "Audio",
"suspend": {
"forTime": "Suspend for: "
},
"stream": {
"title": "Stream",
"audio": {
"tips": {
"title": "Audio must be output from your camera and configured in go2rtc for this stream.",
"documentation": "Read the documentation "
},
"available": "Audio is available for this stream",
"unavailable": "Audio is not available for this stream"
},
"twoWayTalk": {
"tips": "Your device must support the feature and WebRTC must be configured for two-way talk.",
"tips.documentation": "Read the documentation ",
"available": "Two-way talk is available for this stream",
"unavailable": "Two-way talk is unavailable for this stream"
},
"lowBandwidth": {
"tips": "Live view is in low-bandwidth mode due to buffering or stream errors.",
"resetStream": "Reset stream"
},
"playInBackground": {
"label": "Play in background",
"tips": "Enable this option to continue streaming when the player is hidden."
}
},
"cameraSettings": {
"title": "{{camera}} Settings",
"cameraEnabled": "Camera Enabled",
"objectDetection": "Object Detection",
"recording": "Recording",
"snapshots": "Snapshots",
"audioDetection": "Audio Detection",
"autotracking": "Autotracking"
},
"history": {
"label": "Show historical footage"
},
"effectiveRetainMode": {
"modes": {
"all": "All",
"motion": "Motion",
"active_objects": "Active Objects"
},
"notAllTips": "Your {{source}} recording retention configuration is set to <code>mode: {{effectiveRetainMode}}</code>, so this on-demand recording will only keep segments with {{effectiveRetainModeName}}."
},
"editLayout": {
"label": "Edit Layout",
"group": {
"label": "Edit Camera Group"
},
"exitEdit": "Exit Editing"
}
}

View File

@ -0,0 +1,12 @@
{
"export": "Export",
"calendar": "Calendar",
"filter": "Filter",
"filters": "Filters",
"toast": {
"error": {
"noValidTimeSelected": "No valid time range selected",
"endTimeMustAfterStartTime": "End time must be after start time"
}
}
}

View File

@ -0,0 +1,67 @@
{
"search": "Search",
"savedSearches": "Saved Searches",
"searchFor": "Search for {{inputValue}}",
"button": {
"clear": "Clear search",
"save": "Save search",
"delete": "Delete saved search",
"filterInformation": "Filter information",
"filterActive": "Filters active"
},
"trackedObjectId": "Tracked Object ID",
"filter": {
"label": {
"cameras": "Cameras",
"labels": "Labels",
"zones": "Zones",
"sub_labels": "Sub Labels",
"search_type": "Search Type",
"time_range": "Time Range",
"before": "Before",
"after": "After",
"min_score": "Min Score",
"max_score": "Max Score",
"min_speed": "Min Speed",
"max_speed": "Max Speed",
"recognized_license_plate": "Recognized License Plate",
"has_clip": "Has Clip",
"has_snapshot": "Has Snapshot"
},
"searchType": {
"thumbnail": "Thumbnail",
"description": "Description"
},
"toast": {
"error": {
"beforeDateBeLaterAfter": "The 'before' date must be later than the 'after' date.",
"afterDatebeEarlierBefore": "The 'after' date must be earlier than the 'before' date.",
"minScoreMustBeLessOrEqualMaxScore": "The 'min_score' must be less than or equal to the 'max_score'.",
"maxScoreMustBeGreaterOrEqualMinScore": "The 'max_score' must be greater than or equal to the 'min_score'.",
"minSpeedMustBeLessOrEqualMaxSpeed": "The 'min_speed' must be less than or equal to the 'max_speed'.",
"maxSpeedMustBeGreaterOrEqualMinSpeed": "The 'max_speed' must be greater than or equal to the 'min_speed'."
}
},
"tips": {
"title": "How to use text filters",
"desc": {
"text": "Filters help you narrow down your search results. Here's how to use them in the input field:",
"step": "<ul className=\"list-disc pl-5 text-sm text-primary-variant\"><li>Type a filter name followed by a colon (e.g., \"cameras:\").</li><li>Select a value from the suggestions or type your own.</li><li>Use multiple filters by adding them one after another with a space in between.</li><li>Date filters (before: and after:) use <em>{{DateFormat}}</em> format.</li><li>Time range filter uses <em>{{exampleTime}}</em> format.</li><li>Remove filters by clicking the 'x' next to them.</li></ul>",
"example": "Example: <code className=\"text-primary\">cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM </code>"
}
},
"header": {
"currentFilterType": "Filter Values",
"noFilters": "Filters",
"activeFilters": "Active Filters"
}
},
"similaritySearch": {
"title": "Similarity Search",
"active": "Similarity search active",
"clear": "Clear similarity search"
},
"placeholder": {
"search": "Search..."
}
}

View File

@ -0,0 +1,553 @@
{
"documentTitle": {
"default": "Settings - Frigate",
"authentication": "Authentication Settings - Frigate",
"camera": "Camera Settings - Frigate",
"classification": "Classification Settings - Frigate",
"masksAndZones": "Mask and Zone Editor - Frigate",
"motionTuner": "Motion Tuner - Frigate",
"object": "Object Settings - Frigate",
"general": "General Settings - Frigate",
"frigatePlus": "Frigate+ Settings - Frigate"
},
"menu": {
"uiSettings": "UI Settings",
"classificationSettings": "Classification Settings",
"cameraSettings": "Camera Settings",
"masksAndZones": "Masks / Zones",
"motionTuner": "Motion Tuner",
"debug": "Debug",
"users": "Users",
"notifications": "Notifications",
"frigateplus": "Frigate+"
},
"dialog": {
"unsavedChanges": {
"title": "You have unsaved changes.",
"desc": "Do you want to save your changes before continuing?"
}
},
"cameraSetting": {
"camera": "Camera",
"noCamera": "No Camera"
},
"general": {
"title": "General Settings",
"liveDashboard": {
"title": "Live Dashboard",
"automaticLiveView": {
"label": "Automatic Live View",
"desc": "Automatically switch to a camera's live view when activity is detected. Disabling this option causes static camera images on the Live dashboard to only update once per minute."
},
"playAlertVideos": {
"label": "Play Alert Videos",
"desc": "By default, recent alerts on the Live dashboard play as small looping videos. Disable this option to only show a static image of recent alerts on this device/browser."
}
},
"storedLayouts": {
"title": "Stored Layouts",
"desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.",
"clearAll": "Clear All Layouts"
},
"cameraGroupStreaming": {
"title": "Camera Group Streaming Settings",
"desc": "Streaming settings for each camera group are stored in your browser's local storage.",
"clearAll": "Clear All Streaming Settings"
},
"recordingsViewer": {
"title": "Recordings Viewer",
"defaultPlaybackRate": {
"label": "Default Playback Rate",
"desc": "Default playback rate for recordings playback."
}
},
"calendar": {
"title": "Calendar",
"firstWeekday": {
"label": "First Weekday",
"desc": "The day that the weeks of the review calendar begin on.",
"sunday": "Sunday",
"monday": "Monday"
}
},
"toast": {
"success": {
"clearStoredLayout": "Cleared stored layout for {{cameraName}}",
"clearStreamingSettings": "Cleared streaming settings for all camera groups."
},
"error": {
"clearStoredLayoutFailed": "Failed to clear stored layout: {{errorMessage}}",
"clearStreamingSettingsFailed": "Failed to clear streaming settings: {{errorMessage}}"
}
}
},
"classification": {
"title": "Classification Settings",
"semanticSearch": {
"title": "Semantic Search",
"desc": "Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one.",
"readTheDocumentation": "Read the Documentation",
"reindexOnStartup": {
"label": "Re-Index On Startup",
"desc": "Re-indexing will reprocess all thumbnails and descriptions (if enabled) and apply the embeddings on each startup. <em>Don't forget to disable the option after restarting!</em>"
},
"modelSize": {
"label": "Model Size",
"desc": "The size of the model used for semantic search embeddings.",
"small": {
"title": "small",
"desc": "Using <em>small</em> employs a quantized version of the model that uses less RAM and runs faster on CPU with a very negligible difference in embedding quality."
},
"large": {
"title": "large",
"desc": "Using <em>large</em> employs the full Jina model and will automatically run on the GPU if applicable."
}
}
},
"faceRecognition": {
"title": "Face Recognition",
"desc": "Face recognition allows people to be assigned names and when their face is recognized Frigate will assign the person's name as a sub label. This information is included in the UI, filters, as well as in notifications.",
"readTheDocumentation": "Read the Documentation"
},
"licensePlateRecognition": {
"title": "License Plate Recognition",
"desc": "Frigate can recognize license plates on vehicles and automatically add the detected characters to the recognized_license_plate field or a known name as a sub_label to objects that are of type car. A common use case may be to read the license plates of cars pulling into a driveway or cars passing by on a street.",
"readTheDocumentation": "Read the Documentation"
},
"toast": {
"success": "Classification settings have been saved.",
"error": "Failed to save config changes: {{errorMessage}}"
}
},
"camera": {
"title": "Camera Settings",
"streams": {
"title": "Streams",
"desc": "Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>"
},
"review": {
"title": "Review",
"desc": "Enable/disable alerts and detections for this camera. When disabled, no new review items will be generated.",
"alerts": "Alerts ",
"detections": "Detections "
},
"reviewClassification": {
"title": "Review Classification",
"desc": "Frigate categorizes review items as Alerts and Detections. By default, all <em>person</em> and <em>car</em> objects are considered Alerts. You can refine categorization of your review items by configuring required zones for them.",
"readTheDocumentation": "Read the Documentation",
"noDefinedZones": "No zones are defined for this camera.",
"objectAlertsTips": "All {{alertsLabels}} objects on {{cameraName}} will be shown as Alerts.",
"zoneObjectAlertsTips": "All {{alertsLabels}} objects detected in {{zone}} on {{cameraName}} will be shown as Alerts.",
"objectDetectionsTips": "All {{detectionsLabels}} objects not categorized on {{cameraName}} will be shown as Detections regardless of which zone they are in.",
"zoneObjectDetectionsTips": {
"text": "All {{detectionsLabels}} objects not categorized in {{zone}} on {{cameraName}} will be shown as Detections.",
"notSelectDetections": "All {{detectionsLabels}} objects detected in {{zone}} on {{cameraName}} not categorized as Alerts will be shown as Detections regardless of which zone they are in.",
"regardlessOfZoneObjectDetectionsTips": "All {{detectionsLabels}} objects not categorized on {{cameraName}} will be shown as Detections regardless of which zone they are in."
},
"selectAlertsZones": "Select zones for Alerts",
"selectDetectionsZones": "Select zones for Detections",
"limitDetections": "Limit detections to specific zones",
"toast": {
"success": "Review classification configuration has been saved. Restart Frigate to apply changes."
}
}
},
"masksAndZones": {
"filter": {
"all": "All Masks and Zones"
},
"toast": {
"success": {
"copyCoordinates": "Copied coordinates for {{polyName}} to clipboard."
},
"error": {
"copyCoordinatesFailed": "Could not copy coordinates to clipboard."
}
},
"form": {
"zoneName": {
"error": {
"mustBeAtLeastTwoCharacters": "Zone name must be at least 2 characters.",
"mustNotBeSameWithCamera": "Zone name must not be the same as camera name.",
"alreadyExists": "A zone with this name already exists for this camera.",
"mustNotContainPeriod": "Zone name must not contain periods.",
"hasIllegalCharacter": "Zone name contains illegal characters."
}
},
"distance": {
"error": {
"text": "Distance must be greater than or equal to 0.1.",
"mustBeFilled": "All distance fields must be filled to use speed estimation."
}
},
"inertia": {
"error": {
"mustBeAboveZero": "Inertia must be above 0."
}
},
"loiteringTime": {
"error": {
"mustBeGreaterOrEqualZero": "Loitering time must be greater than or equal to 0."
}
},
"polygonDrawing": {
"removeLastPoint": "Remove last point",
"reset": {
"label": "Clear all points"
},
"snapPoints": {
"true": "Snap points",
"false": "Don't Snap points"
},
"delete": {
"title": "Confirm Delete",
"desc": "Are you sure you want to delete the {{type}} <em>{{name}}</em>?",
"success": "{{name}} has been deleted."
},
"error": {
"mustBeFinished": "Polygon drawing must be finished before saving."
}
}
},
"zones": {
"label": "Zones",
"documentTitle": "Edit Zone - Frigate",
"desc": {
"title": "Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.",
"documentation": "Documentation"
},
"add": "Add Zone",
"edit": "Edit Zone",
"point_one": "{{count}} point",
"point_other": "{{count}} points",
"clickDrawPolygon": "Click to draw a polygon on the image.",
"name": {
"title": "Name",
"inputPlaceHolder": "Enter a name...",
"tips": "Name must be at least 2 characters and must not be the name of a camera or another zone."
},
"inertia": {
"title": "Inertia",
"desc": "Specifies how many frames that an object must be in a zone before they are considered in the zone. <em>Default: 3</em>"
},
"loiteringTime": {
"title": "Loitering Time",
"desc": "Sets a minimum amount of time in seconds that the object must be in the zone for it to activate. <em>Default: 0</em>"
},
"objects": {
"title": "Objects",
"desc": "List of objects that apply to this zone."
},
"allObjects": "All Objects",
"speedEstimation": {
"title": "Speed Estimation",
"desc": "Enable speed estimation for objects in this zone. The zone must have exactly 4 points."
},
"speedThreshold": {
"title": "Speed Threshold ({{unit}})",
"desc": "Specifies a minimum speed for objects to be considered in this zone.",
"toast": {
"error": {
"pointLengthError": "Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.",
"loiteringTimeError": "Zones with loitering times greater than 0 should not be used with speed estimation."
}
}
},
"toast": {
"success": "Zone ({{zoneName}}) has been saved. Restart Frigate to apply changes."
}
},
"motionMasks": {
"label": "Motion Mask",
"documentTitle": "Edit Motion Mask - Frigate",
"desc": {
"title": "Motion masks are used to prevent unwanted types of motion from triggering detection. Over masking will make it more difficult for objects to be tracked.",
"documentation": "Documentation"
},
"add": "New Motion Mask",
"edit": "Edit Motion Mask",
"context": {
"title": "Motion masks are used to prevent unwanted types of motion from triggering detection (example: tree branches, camera timestamps). Motion masks should be used <em>very sparingly</em>, over-masking will make it more difficult for objects to be tracked.",
"documentation": "Read the documentation"
},
"point_one": "{{count}} point",
"point_other": "{{count}} points",
"clickDrawPolygon": "Click to draw a polygon on the image.",
"polygonAreaTooLarge": {
"title": "The motion mask is covering {{polygonArea}}% of the camera frame. Large motion masks are not recommended.",
"tips": "Motion masks do not prevent objects from being detected. You should use a required zone instead.",
"documentation": "Read the documentation"
},
"toast": {
"success": {
"title": "{{polygonName}} has been saved. Restart Frigate to apply changes.",
"noName": "Motion Mask has been saved. Restart Frigate to apply changes."
}
}
},
"objectMasks": {
"label": "Object Masks",
"documentTitle": "Edit Object Mask - Frigate",
"desc": {
"title": "Object filter masks are used to filter out false positives for a given object type based on location.",
"documentation": "Documentation"
},
"add": "Add Object Mask",
"edit": "Edit Object Mask",
"context": "Object filter masks are used to filter out false positives for a given object type based on location.",
"point_one": "{{count}} point",
"point_other": "{{count}} points",
"clickDrawPolygon": "Click to draw a polygon on the image.",
"objects": {
"title": "Objects",
"desc": "The object type that that applies to this object mask.",
"allObjectTypes": "All object types"
},
"toast": {
"success": {
"title": "{{polygonName}} has been saved. Restart Frigate to apply changes.",
"noName": "Object Mask has been saved. Restart Frigate to apply changes."
}
}
}
},
"motionDetectionTuner": {
"title": "Motion Detection Tuner",
"desc": {
"title": "Frigate uses motion detection as a first line check to see if there is anything happening in the frame worth checking with object detection.",
"documentation": "Read the Motion Tuning Guide"
},
"Threshold": {
"title": "Threshold",
"desc": "The threshold value dictates how much of a change in a pixel's luminance is required to be considered motion. <em>Default: 30</em>"
},
"contourArea": {
"title": "Contour Area",
"desc": "The contour area value is used to decide which groups of changed pixels qualify as motion. <em>Default: 10</em>"
},
"improveContrast": {
"title": "Improve Contrast",
"desc": "Improve contrast for darker scenes. <em>Default: ON</em>"
},
"toast": {
"success": "Motion settings have been saved."
}
},
"debug": {
"title": "Debug",
"detectorDesc": "Frigate uses your detectors ({{detectors}}) to detect objects in your camera's video stream.",
"desc": "Debugging view shows a real-time view of tracked objects and their statistics. The object list shows a time-delayed summary of detected objects.",
"debugging": "Debugging",
"objectList": "Object List",
"noObjects": "No objects",
"boundingBoxes": {
"title": "Bounding boxes",
"desc": "Show bounding boxes around tracked objects",
"colors": {
"label": "Object Bounding Box Colors",
"info": "<li>At startup, different colors will be assigned to each object label</li><li>A dark blue thin line indicates that object is not detected at this current point in time</li><li>A gray thin line indicates that object is detected as being stationary</li><li>A thick line indicates that object is the subject of autotracking (when enabled)</li>"
}
},
"timestamp": {
"title": "Timestamp",
"desc": "Overlay a timestamp on the image"
},
"zones": {
"title": "Zones",
"desc": "Show an outline of any defined zones"
},
"mask": {
"title": "Motion masks",
"desc": "Show motion mask polygons"
},
"motion": {
"title": "Motion boxes",
"desc": "Show boxes around areas where motion is detected",
"tips": "<p className=\"mb-2\"><strong>Motion Boxes</strong></p><br><p>Red boxes will be overlaid on areas of the frame where motion is currently being detected</p>"
},
"regions": {
"title": "Regions",
"desc": "Show a box of the region of interest sent to the object detector",
"tips": "<p className=\"mb-2\"><strong>Region Boxes</strong></p><br><p>Bright green boxes will be overlaid on areas of interest in the frame that are being sent to the object detector.</p>"
},
"objectShapeFilterDrawing": {
"title": "Object Shape Filter Drawing",
"desc": "Draw a rectangle on the image to view area and ratio details",
"tips": "Enable this option to draw a rectangle on the camera image to show its area and ratio. These values can then be used to set object shape filter parameters in your config.",
"document": "Read the documentation ",
"score": "Score",
"ratio": "Ratio",
"area": "Area"
}
},
"users": {
"title": "Users",
"management": {
"title": "User Management",
"desc": "Manage this Frigate instance's user accounts."
},
"addUser": "Add User",
"updatePassword": "Update Password",
"toast": {
"success": {
"createUser": "User {{user}} created successfully",
"deleteUser": "User {{user}} deleted successfully",
"updatePassword": "Password updated successfully.",
"roleUpdated": "Role updated for {{user}}"
},
"error": {
"setPasswordFailed": "Failed to save password: {{errorMessage}}",
"createUserFailed": "Failed to create user: {{errorMessage}}",
"deleteUserFailed": "Failed to delete user: {{errorMessage}}",
"roleUpdateFailed": "Failed to update role: {{errorMessage}}"
}
},
"table": {
"username": "Username",
"actions": "Actions",
"role": "Role",
"noUsers": "No users found.",
"changeRole": "Change user role",
"password": "Password",
"deleteUser": "Delete user"
},
"dialog": {
"form": {
"user": {
"title": "Username",
"desc": "Only letters, numbers, periods and underscores allowed.",
"placeholder": "Enter username"
},
"password": {
"title": "Password",
"placeholder": "Enter password",
"confirm": {
"title": "Confirm Password",
"placeholder": "Confirm Password"
},
"strength": {
"title": "Password strength: ",
"weak": "Weak",
"medium": "Medium",
"strong": "Strong",
"veryStrong": "Very Strong"
},
"match": "Passwords match",
"notMatch": "Passwords don't match"
},
"newPassword": {
"title": "New Password",
"placeholder": "Enter new password",
"confirm": {
"placeholder": "Re-enter new password"
}
},
"usernameIsRequired": "Username is required"
},
"createUser": {
"title": "Create New User",
"desc": "Add a new user account and specify an role for access to areas of the Frigate UI.",
"usernameOnlyInclude": "Username may only include letters, numbers, . or _"
},
"deleteUser": {
"title": "Delete User",
"desc": "This action cannot be undone. This will permanently delete the user account and remove all associated data.",
"warn": "Are you sure you want to delete <span className=\"font-bold\">{{username}}</span>?"
},
"passwordSetting": {
"updatePassword": "Update Password for {{username}}",
"setPassword": "Set Password",
"desc": "Create a strong password to secure this account."
},
"changeRole": {
"title": "Change User Role",
"desc": "Update permissions for <span className=\"font-medium\">{{username}}</span>",
"roleInfo": "<p>Select the appropriate role for this user:</p><ul className=\"mt-2 space-y-1 pl-5\"><li> • <span className=\"font-medium\">Admin:</span> Full access to all features. </li><li> • <span className=\"font-medium\">Viewer:</span> Limited to Live dashboards, Review, Explore, and Exports only.</li></ul>"
}
}
},
"notification": {
"title": "Notifications",
"notificationSettings": {
"title": "Notification Settings",
"desc": "Frigate can natively send push notifications to your device when it is running in the browser or installed as a PWA.",
"documentation": "Read the Documentation"
},
"notificationUnavailable": {
"title": "Notifications Unavailable",
"desc": "Web push notifications require a secure context (<code>https://...</code>). This is a browser limitation. Access Frigate securely to use notifications.",
"documentation": "Read the Documentation"
},
"globalSettings": {
"title": "Global Settings",
"desc": "Temporarily suspend notifications for specific cameras on all registered devices."
},
"email": {
"title": "Email",
"placeholder": "e.g. example@email.com",
"desc": "A valid email is required and will be used to notify you if there are any issues with the push service."
},
"cameras": {
"title": "Cameras",
"noCameras": "No cameras available",
"desc": "Select which cameras to enable notifications for."
},
"deviceSpecific": "Device Specific Settings",
"registerDevice": "Register This Device",
"unregisterDevice": "Unregister This Device",
"sendTestNotification": "Send a test notification",
"active": "Notifications Active",
"suspended": "Notifications suspended {{time}}",
"suspendTime": {
"5minutes": "Suspend for 5 minutes",
"10minutes": "Suspend for 10 minutes",
"30minutes": "Suspend for 30 minutes",
"1hour": "Suspend for 1 hour",
"12hours": "Suspend for 12 hours",
"24hours": "Suspend for 24 hours",
"untilRestart": "Suspend until restart"
},
"cancelSuspension": "Cancel Suspension",
"toast": {
"success": {
"registered": "Successfully registered for notifications. Restarting Frigate is required before any notifications (including a test notification) can be sent.",
"settingSaved": "Notification settings have been saved."
},
"error": {
"registerFailed": "Failed to save notification registration."
}
}
},
"frigatePlus": {
"title": "Frigate+ Settings",
"apiKey": {
"title": "Frigate+ API Key",
"validated": "Frigate+ API key is detected and validated",
"notValidated": "Frigate+ API key is not detected or not validated",
"desc": "The Frigate+ API key enables integration with the Frigate+ service.",
"plusLink": "Read more about Frigate+"
},
"snapshotConfig": {
"title": "Snapshot Configuration",
"desc": "Submitting to Frigate+ requires both snapshots and <code>clean_copy</code> snapshots to be enabled in your config.",
"documentation": "Read the documentation",
"cleanCopyWarning": "Some cameras have snapshots enabled but have the clean copy disabled. You need to enable <code>clean_copy</code> in your snapshot config to be able to submit images from these cameras to Frigate+.",
"table": {
"camera": "Camera",
"snapshots": "Snapshots",
"cleanCopySnapshots": "<code>clean_copy</code> Snapshots"
}
},
"modelInfo": {
"title": "Model Information",
"modelId": "Model ID",
"modelType": "Model Type",
"trainDate": "Train Date",
"baseModel": "Base Model",
"supportedDetectors": "Supported Detectors",
"cameras": "Cameras",
"loading": "Loading model information...",
"error": "Failed to load model information"
}
}
}

View File

@ -0,0 +1,156 @@
{
"documentTitle": {
"cameras": "Cameras Stats - Frigate",
"storage": "Storage Stats - Frigate",
"general": "General Stats - Frigate",
"features": "Features Stats - Frigate",
"logs": {
"frigate": "Frigate Logs - Frigate",
"go2rtc": "Go2RTC Logs - Frigate",
"nginx": "Nginx Logs - Frigate"
}
},
"title": "System",
"metrics": "System metrics",
"logs": {
"download": {
"label": "Download Logs"
},
"copy": {
"label": "Copy to Clipboard",
"success": "Copied logs to clipboard",
"error": "Could not copy logs to clipboard"
},
"type": {
"label": "Type",
"timestamp": "Timestamp",
"tag": "Tag",
"message": "Message"
},
"tips": "Logs are streaming from the server",
"toast": {
"error": {
"fetchingLogsFailed": "Error fetching logs: {{errorMessage}}",
"whileStreamingLogs": "Error while streaming logs: {{errorMessage}}"
}
}
},
"general": {
"title": "General",
"detector": {
"title": "Detectors",
"inferenceSpeed": "Detector Inference Speed",
"cpuUsage": "Detector CPU Usage",
"memoryUsage": "Detector Memory Usage"
},
"hardwareInfo": {
"title": "Hardware Info",
"gpuUsage": "GPU Usage",
"gpuMemory": "GPU Memory",
"gpuEncoder": "GPU Encoder",
"gpuDecoder": "GPU Decoder",
"gpuInfo": {
"vainfoOutput": {
"title": "Vainfo Output",
"returnCode": "Return Code: {{code}}",
"processOutput": "Process Output:",
"processError": "Process Error:"
},
"nvidiaSMIOutput": {
"title": "Nvidia SMI Output",
"name": "Name: {{name}}",
"driver": "Driver: {{driver}}",
"cudaComputerCapability": "CUDA Compute Capability: {{cuda_compute}}",
"vbios": "VBios Info: {{vbios}}"
},
"closeInfo": {
"label": "Close GPU info"
},
"copyInfo": {
"label": "Copy GPU info"
},
"toast": {
"success": "Copied GPU info to clipboard"
}
}
},
"otherProcesses": {
"title": "Other Processes",
"processCpuUsage": "Process CPU Usage",
"processMemoryUsage": "Process Memory Usage"
}
},
"storage": {
"title": "Storage",
"overview": "Overview",
"recordings": {
"title": "Recordings",
"tips": "This value represents the total storage used by the recordings in Frigate's database. Frigate does not track storage usage for all files on your disk.",
"earliestRecording": "Earliest recording available:"
},
"cameraStorage": {
"title": "Camera Storage",
"camera": "Camera",
"unusedStorageInformation": "Unused Storage Information",
"storageUsed": "Storage",
"percentageOfTotalUsed": "Percentage of Total",
"bandwidth": "Bandwidth",
"unused": {
"title": "Unused",
"tips": "This value may not accurately represent the free space available to Frigate if you have other files stored on your drive beyond Frigate's recordings. Frigate does not track storage usage outside of its recordings."
}
}
},
"cameras": {
"title": "Cameras",
"overview": "Overview",
"info": {
"cameraProbeInfo": "{{camera}} Camera Probe Info",
"streamDataFromFFPROBE": "Stream data is obtained with <code>ffprobe</code>.",
"fetching": "Fetching Camera Data",
"stream": "Stream {{idx}}",
"video": "Video:",
"codec": "Codec:",
"resolution": "Resolution:",
"fps": "FPS:",
"unknown": "Unknown",
"audio": "Audio:",
"error": "Error: {{error}}",
"tips": {
"title": "Camera Probe Info"
}
},
"framesAndDetections": "Frames / Detections",
"label": {
"camera": "camera",
"detect": "detect",
"skipped": "skipped",
"ffmpeg": "ffmpeg",
"capture": "capture"
},
"toast": {
"success": {
"copyToClipboard": "Copied probe data to clipboard."
},
"error": {
"unableToProbeCamera": "Unable to probe camera: {{errorMessage}}"
}
}
},
"lastRefreshed": "Last refreshed: ",
"stats": {
"ffmpegHighCpuUsage": "{{camera}} has high FFMPEG CPU usage ({{ffmpegAvg}}%)",
"detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
"healthy": "System is healthy",
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)"
},
"features": {
"title": "Features",
"embeddings": {
"image_embedding_speed": "Image Embedding Speed",
"face_embedding_speed": "Face Embedding Speed",
"plate_recognition_speed": "Plate Recognition Speed",
"text_embedding_speed": "Text Embedding Speed"
}
}
}

View File

@ -0,0 +1,429 @@
{
"speech": "谈话",
"babbling": "喋喋不休",
"yell": "大喊",
"bellow": "吼叫",
"whoop": "欢呼",
"whispering": "耳语",
"laughter": "笑声",
"snicker": "窃笑",
"crying": "哭泣",
"sigh": "叹息",
"singing": "唱歌",
"choir": "合唱",
"yodeling": "山歌",
"chant": "吟唱",
"mantra": "咒语",
"child_singing": "儿童歌唱",
"synthetic_singing": "合成歌声",
"rapping": "说唱",
"humming": "哼唱",
"groan": "呻吟",
"grunt": "咕哝",
"whistling": "口哨",
"breathing": "呼吸",
"wheeze": "喘息",
"snoring": "打鼾",
"gasp": "倒抽气",
"pant": "喘气",
"snort": "哼声",
"cough": "咳嗽",
"throat_clearing": "清嗓子",
"sneeze": "打喷嚏",
"sniff": "抽鼻子",
"run": "跑步",
"shuffle": "拖步",
"footsteps": "脚步声",
"chewing": "咀嚼",
"biting": "咬",
"gargling": "漱口",
"stomach_rumble": "肚子咕噜",
"burping": "打嗝",
"hiccup": "打嗝",
"fart": "放屁",
"hands": "手",
"finger_snapping": "打响指",
"clapping": "鼓掌",
"heartbeat": "心跳",
"heart_murmur": "心脏杂音",
"cheering": "欢呼",
"applause": "掌声",
"chatter": "闲聊",
"crowd": "人群",
"children_playing": "儿童玩耍",
"animal": "动物",
"pets": "宠物",
"dog": "狗",
"bark": "吠叫",
"yip": "吠叫",
"howl": "嚎叫",
"bow_wow": "汪汪",
"growling": "咆哮",
"whimper_dog": "狗呜咽",
"cat": "猫",
"purr": "咕噜",
"meow": "喵喵",
"hiss": "嘶嘶声",
"caterwaul": "猫叫春",
"livestock": "牲畜",
"horse": "马",
"clip_clop": "蹄声",
"neigh": "嘶鸣",
"cattle": "牛",
"moo": "哞哞",
"cowbell": "牛铃",
"pig": "猪",
"oink": "哼哼",
"goat": "山羊",
"bleat": "咩咩",
"sheep": "绵羊",
"fowl": "家禽",
"chicken": "鸡",
"cluck": "咯咯",
"cock_a_doodle_doo": "喔喔",
"turkey": "火鸡",
"gobble": "咯咯",
"duck": "鸭子",
"quack": "嘎嘎",
"goose": "鹅",
"honk": "鸣笛/鹅叫声",
"wild_animals": "野生动物",
"roaring_cats": "吼叫的猫科动物",
"roar": "吼叫",
"bird": "鸟",
"chirp": "啾啾",
"squawk": "啼叫",
"pigeon": "鸽子",
"coo": "咕咕",
"crow": "乌鸦",
"caw": "呱呱",
"owl": "猫头鹰",
"hoot": "呜呜",
"flapping_wings": "翅膀拍打",
"dogs": "狗群",
"rats": "老鼠",
"mouse": "老鼠",
"patter": "啪嗒声",
"insect": "昆虫",
"cricket": "蟋蟀",
"mosquito": "蚊子",
"fly": "苍蝇",
"buzz": "嗡嗡",
"frog": "青蛙",
"croak": "呱呱",
"snake": "蛇",
"rattle": "响尾",
"whale_vocalization": "鲸鱼叫声",
"music": "音乐",
"musical_instrument": "乐器",
"plucked_string_instrument": "弹拨乐器",
"guitar": "吉他",
"electric_guitar": "电吉他",
"bass_guitar": "贝斯",
"acoustic_guitar": "原声吉他",
"steel_guitar": "钢弦吉他",
"tapping": "敲击",
"strum": "扫弦",
"banjo": "班卓琴",
"sitar": "西塔琴",
"mandolin": "曼陀林",
"zither": "古筝",
"ukulele": "尤克里里",
"keyboard": "键盘",
"piano": "钢琴",
"electric_piano": "电钢琴",
"organ": "风琴",
"electronic_organ": "电子琴",
"hammond_organ": "哈蒙德风琴",
"synthesizer": "合成器",
"sampler": "采样器",
"harpsichord": "大键琴",
"percussion": "打击乐器",
"drum_kit": "架子鼓",
"drum_machine": "鼓机",
"drum": "鼓",
"snare_drum": "军鼓",
"rimshot": "鼓边击",
"drum_roll": "滚鼓",
"bass_drum": "大鼓",
"timpani": "定音鼓",
"tabla": "塔布拉鼓",
"cymbal": "钹",
"hi_hat": "踩镲",
"wood_block": "木鱼",
"tambourine": "铃鼓",
"maraca": "沙锤",
"gong": "锣",
"tubular_bells": "管钟",
"mallet_percussion": "槌击打击乐器",
"marimba": "马林巴",
"glockenspiel": "钟琴",
"vibraphone": "颤音琴",
"steelpan": "钢鼓",
"orchestra": "管弦乐队",
"brass_instrument": "铜管乐器",
"french_horn": "圆号",
"trumpet": "小号",
"trombone": "长号",
"bowed_string_instrument": "弓弦乐器",
"string_section": "弦乐组",
"violin": "小提琴",
"pizzicato": "拨弦",
"cello": "大提琴",
"double_bass": "低音提琴",
"wind_instrument": "管乐器",
"flute": "长笛",
"saxophone": "萨克斯",
"clarinet": "单簧管",
"harp": "竖琴",
"bell": "铃",
"church_bell": "教堂钟",
"jingle_bell": "铃铛",
"bicycle_bell": "自行车铃",
"tuning_fork": "音叉",
"chime": "风铃",
"wind_chime": "风铃",
"harmonica": "口琴",
"accordion": "手风琴",
"bagpipes": "风笛",
"didgeridoo": "迪吉里杜管",
"theremin": "特雷门琴",
"singing_bowl": "颂钵",
"scratching": "刮擦声",
"pop_music": "流行音乐",
"hip_hop_music": "嘻哈音乐",
"beatboxing": "人声节拍",
"rock_music": "摇滚音乐",
"heavy_metal": "重金属",
"punk_rock": "朋克摇滚",
"grunge": "垃圾摇滚",
"progressive_rock": "前卫摇滚",
"rock_and_roll": "摇滚乐",
"psychedelic_rock": "迷幻摇滚",
"rhythm_and_blues": "节奏布鲁斯",
"soul_music": "灵魂乐",
"reggae": "雷鬼",
"country": "乡村音乐",
"swing_music": "摇摆乐",
"bluegrass": "蓝草音乐",
"funk": "放克",
"folk_music": "民谣",
"middle_eastern_music": "中东音乐",
"jazz": "爵士乐",
"disco": "迪斯科",
"classical_music": "古典音乐",
"opera": "歌剧",
"electronic_music": "电子音乐",
"house_music": "浩室音乐",
"techno": "科技舞曲",
"dubstep": "回响贝斯",
"drum_and_bass": "鼓打贝斯",
"electronica": "电子乐",
"electronic_dance_music": "电子舞曲",
"ambient_music": "环境音乐",
"trance_music": "迷幻舞曲",
"music_of_latin_america": "拉丁美洲音乐",
"salsa_music": "萨尔萨",
"flamenco": "弗拉门戈",
"blues": "蓝调",
"music_for_children": "儿童音乐",
"new-age_music": "新世纪音乐",
"vocal_music": "声乐",
"a_capella": "无伴奏合唱",
"music_of_africa": "非洲音乐",
"afrobeat": "非洲节拍",
"christian_music": "基督教音乐",
"gospel_music": "福音音乐",
"music_of_asia": "亚洲音乐",
"carnatic_music": "卡纳提克音乐",
"music_of_bollywood": "宝莱坞音乐",
"ska": "斯卡",
"traditional_music": "传统音乐",
"independent_music": "独立音乐",
"song": "歌曲",
"background_music": "背景音乐",
"theme_music": "主题音乐",
"jingle": "广告歌",
"soundtrack_music": "配乐",
"lullaby": "摇篮曲",
"video_game_music": "电子游戏音乐",
"christmas_music": "圣诞音乐",
"dance_music": "舞曲",
"wedding_music": "婚礼音乐",
"happy_music": "欢快音乐",
"sad_music": "悲伤音乐",
"tender_music": "温柔音乐",
"exciting_music": "激动音乐",
"angry_music": "愤怒音乐",
"scary_music": "恐怖音乐",
"wind": "风",
"rustling_leaves": "树叶沙沙声",
"wind_noise": "风声",
"thunderstorm": "雷暴",
"thunder": "雷声",
"water": "水",
"rain": "雨",
"raindrop": "雨滴",
"rain_on_surface": "雨打表面",
"stream": "溪流",
"waterfall": "瀑布",
"ocean": "海洋",
"waves": "波浪",
"steam": "蒸汽",
"gurgling": "汩汩声",
"fire": "火",
"crackle": "噼啪声",
"vehicle": "车辆",
"boat": "船",
"sailboat": "帆船",
"rowboat": "划艇",
"motorboat": "摩托艇",
"ship": "轮船",
"motor_vehicle": "机动车",
"car": "汽车",
"toot": "鸣笛",
"car_alarm": "汽车警报",
"power_windows": "电动车窗",
"skidding": "轮胎打滑",
"tire_squeal": "轮胎尖叫",
"car_passing_by": "汽车驶过",
"race_car": "赛车",
"truck": "卡车",
"air_brake": "气闸",
"air_horn": "气笛",
"reversing_beeps": "倒车提示音",
"ice_cream_truck": "冰淇淋车",
"bus": "公共汽车",
"emergency_vehicle": "应急车辆",
"police_car": "警车",
"ambulance": "救护车",
"fire_engine": "消防车",
"motorcycle": "摩托车",
"traffic_noise": "交通噪音",
"rail_transport": "铁路运输",
"train": "火车",
"train_whistle": "火车汽笛",
"train_horn": "火车鸣笛",
"railroad_car": "铁路车厢",
"train_wheels_squealing": "火车轮子尖叫",
"subway": "地铁",
"aircraft": "飞行器",
"aircraft_engine": "飞机引擎",
"jet_engine": "喷气引擎",
"propeller": "螺旋桨",
"helicopter": "直升机",
"fixed-wing_aircraft": "固定翼飞机",
"bicycle": "自行车",
"skateboard": "滑板",
"engine": "引擎",
"light_engine": "轻型引擎",
"dental_drill's_drill": "牙科钻",
"lawn_mower": "割草机",
"chainsaw": "电锯",
"medium_engine": "中型引擎",
"heavy_engine": "重型引擎",
"engine_knocking": "引擎敲击",
"engine_starting": "引擎启动",
"idling": "怠速",
"accelerating": "加速",
"door": "门",
"doorbell": "门铃",
"ding-dong": "叮咚",
"sliding_door": "滑动门",
"slam": "猛关",
"knock": "敲门",
"tap": "轻敲",
"squeak": "吱吱声",
"cupboard_open_or_close": "橱柜开关",
"drawer_open_or_close": "抽屉开关",
"dishes": "餐具",
"cutlery": "刀叉",
"chopping": "切菜",
"frying": "煎炸",
"microwave_oven": "微波炉",
"blender": "搅拌机",
"water_tap": "水龙头",
"sink": "水槽",
"bathtub": "浴缸",
"hair_dryer": "吹风机",
"toilet_flush": "马桶冲水",
"toothbrush": "牙刷",
"electric_toothbrush": "电动牙刷",
"vacuum_cleaner": "吸尘器",
"zipper": "拉链",
"keys_jangling": "钥匙叮当",
"coin": "硬币",
"scissors": "剪刀",
"electric_shaver": "电动剃须刀",
"shuffling_cards": "洗牌",
"typing": "打字",
"typewriter": "打字机",
"computer_keyboard": "电脑键盘",
"writing": "书写",
"alarm": "警报",
"telephone": "电话",
"telephone_bell_ringing": "电话铃声",
"ringtone": "手机铃声",
"telephone_dialing": "电话拨号",
"dial_tone": "拨号音",
"busy_signal": "忙音",
"alarm_clock": "闹钟",
"siren": "警笛",
"civil_defense_siren": "防空警报",
"buzzer": "蜂鸣器",
"smoke_detector": "烟雾探测器",
"fire_alarm": "火灾警报器",
"foghorn": "雾笛",
"whistle": "哨子",
"steam_whistle": "蒸汽汽笛",
"mechanisms": "机械装置",
"ratchet": "棘轮",
"clock": "时钟",
"tick": "滴答",
"tick-tock": "滴答滴答",
"gears": "齿轮",
"pulleys": "滑轮",
"sewing_machine": "缝纫机",
"mechanical_fan": "机械风扇",
"air_conditioning": "空调",
"cash_register": "收银机",
"printer": "打印机",
"camera": "相机",
"single-lens_reflex_camera": "单反相机",
"tools": "工具",
"hammer": "锤子",
"jackhammer": "风镐",
"sawing": "锯",
"filing": "锉",
"sanding": "砂磨",
"power_tool": "电动工具",
"drill": "电钻",
"explosion": "爆炸",
"gunshot": "枪声",
"machine_gun": "机关枪",
"fusillade": "齐射",
"artillery_fire": "炮火",
"cap_gun": "玩具枪",
"fireworks": "烟花",
"firecracker": "鞭炮",
"burst": "爆裂",
"eruption": "爆发",
"boom": "轰隆",
"wood": "木头",
"chop": "砍",
"splinter": "碎裂",
"crack": "破裂",
"glass": "玻璃",
"chink": "叮当",
"shatter": "粉碎",
"silence": "寂静",
"sound_effect": "音效",
"environmental_noise": "环境噪音",
"static": "静电噪音",
"white_noise": "白噪音",
"pink_noise": "粉红噪音",
"television": "电视",
"radio": "收音机",
"field_recording": "实地录音",
"scream": "尖叫"
}

View File

@ -0,0 +1,201 @@
{
"time": {
"untilForTime": "直到 {{time}}",
"untilForRestart": "直到 Frigate 重启。",
"untilRestart": "直到重启",
"ago": "{{timeAgo}} 前",
"justNow": "刚才",
"today": "今天",
"yesterday": "昨天",
"last7": "最后 7 天",
"last14": "最后 14 天",
"last30": "最后 30 天",
"thisWeek": "本周",
"lastWeek": "上个周",
"thisMonth": "本月",
"lastMonth": "上个月",
"5minutes": "5 分钟",
"10minutes": "10 分钟",
"30minutes": "30 分钟",
"1hour": "1 小时",
"12hours": "12 小时",
"24hours": "24 小时",
"pm": "下午",
"am": "上午",
"yr": "{{time}}年",
"year": "{{time}}年",
"mo": "{{time}}月",
"month": "{{time}}月",
"d": "{{time}}天",
"day": "{{time}}天",
"h": "{{time}}小时",
"hour": "{{time}}小时",
"m": "{{time}}分钟",
"minute": "{{time}}分钟",
"s": "{{time}}秒",
"second": "{{time}}秒",
"formattedTimestamp": {
"12hour": "%m月%-d日 %I:%M:%S %p",
"24hour": "%m月%-d日 %H:%M:%S"
},
"formattedTimestamp2": {
"12hour": "%m/%d %I:%M:%S%P",
"24hour": "%d日%m月 %H:%M:%S"
},
"formattedTimestampExcludeSeconds": {
"12hour": "%m月%-d日 %I:%M %p",
"24hour": "%m月%-d日 %H:%M"
},
"formattedTimestampWithYear": {
"12hour": "%Y年%m月%-d日 %I:%M:%S %p",
"24hour": "%Y年%m月%-d日 %H:%M"
},
"formattedTimestampOnlyMonthAndDay": "%m月%-d日"
},
"unit": {
"speed": {
"mph": "英里/小时",
"kph": "公里/小时"
}
},
"label": {
"back": "返回"
},
"pagination": {
"label": "分页",
"previous": {
"title": "上一页",
"label": "转到上一页"
},
"next": {
"title": "下一页",
"label": "转到下一页"
},
"more": "更多页面"
},
"button": {
"apply": "应用",
"reset": "重置",
"done": "完成",
"enabled": "启用",
"enable": "启用",
"disabled": "禁用",
"disable": "禁用",
"save": "保存",
"saving": "保存中……",
"cancel": "取消",
"close": "关闭",
"copy": "复制",
"back": "返回",
"history": "历史",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏",
"pictureInPicture": "画中画",
"on": "开",
"off": "关",
"edit": "编辑",
"copyCoordinates": "复制坐标",
"delete": "删除",
"yes": "是",
"no": "否",
"download": "下载",
"info": "信息",
"suspended": "已暂停",
"unsuspended": "取消暂停",
"play": "播放",
"unselect": "取消选择",
"export": "导出",
"deleteNow": "立即删除",
"next": "下一个"
},
"menu": {
"system": "系统",
"systemMetrics": "系统信息",
"configuration": "配置",
"systemLogs": "系统日志",
"settings": "设置",
"configurationEditor": "配置编辑器",
"languages": "Languages / 语言",
"language": {
"en": "English",
"zhCN": "简体中文",
"withSystem": {
"label": "使用系统语言设置"
}
},
"appearance": "外观",
"darkMode": {
"label": "深色模式",
"light": "浅色",
"dark": "深色",
"withSystem": {
"label": "使用系统深色模式设置"
}
},
"withSystem": "跟随系统",
"theme": {
"label": "主题",
"blue": "蓝色",
"green": "绿色",
"nord": "Nord",
"red": "红色",
"contrast": "高对比度",
"default": "默认"
},
"help": "帮助",
"documentation": {
"title": "文档",
"label": "Frigate 的官方文档"
},
"live": {
"title": "实时监控",
"allCameras": "所有摄像头",
"cameras": {
"title": "摄像头",
"count_one": "{{count}} 个摄像头",
"count_other": "{{count}} 个摄像头"
}
},
"review": "回放",
"explore": "探测",
"export": "导出",
"uiPlayground": "UI 演示",
"faceLibrary": "人脸管理",
"user": {
"account": "账号",
"current": "当前用户:{{user}}",
"anonymous": "匿名",
"logout": "登出",
"setPassword": "设置密码",
"title": "用户"
},
"restart": "重启 Frigate"
},
"toast": {
"copyUrlToClipboard": "已复制链接到剪贴板。",
"save": {
"title": "保存",
"error": {
"title": "保存配置信息失败: {{errorMessage}}",
"noMessage": "保存配置信息失败"
}
}
},
"role": {
"title": "权限组",
"admin": "管理员",
"viewer": "查看者",
"desc": "管理员可以完全访问 Frigate UI 的所有功能。查看者则仅限于在 UI 中查看摄像头、审核项和历史录像。"
},
"accessDenied": {
"documentTitle": "没有权限 - Frigate",
"title": "没有权限",
"desc": "您没有权限查看此页面。"
},
"notFound": {
"documentTitle": "没有找到页面 - Frigate",
"title": "404",
"desc": "页面未找到"
},
"selectItem": "选择 {{item}}"
}

View File

@ -0,0 +1,15 @@
{
"form": {
"user": "用户名",
"password": "密码",
"login": "登录",
"errors": {
"usernameRequired": "用户名不能为空",
"passwordRequired": "密码不能为空",
"rateLimit": "超出请求限制,请稍后再试。",
"loginFailed": "登录失败",
"unknownError": "未知错误,请检查日志。",
"webUnknownError": "未知错误,请检查控制台日志。"
}
}
}

View File

@ -0,0 +1,83 @@
{
"group": {
"label": "摄像头组",
"add": "添加摄像头组",
"edit": "编辑摄像头组",
"delete": {
"label": "删除摄像头组",
"confirm": {
"title": "确认删除",
"desc": "你确定要删除摄像头组 <em>{{name}}</em> 吗?"
}
},
"name": {
"label": "名称",
"placeholder": "请输入名称",
"errorMessage": {
"mustLeastCharacters": "摄像头组的名称必须至少有 2 个字符。",
"exists": "摄像头组名称已存在。",
"nameMustNotPeriod": "摄像头组名称不能包含英文句号(.)。",
"invalid": "无效的摄像头组名称。"
}
},
"cameras": {
"label": "摄像头",
"desc": "选择添加至该组的摄像头。"
},
"icon": "图标",
"success": "摄像头组({{name}})保存成功。",
"camera": {
"setting": {
"label": "摄像头视频流设置",
"title": "{{cameraName}} 视频流设置",
"desc": "更改此摄像头组仪表板的实时视频流选项。<em>这些设置特定于设备/浏览器。</em>",
"audioIsAvailable": "此视频流支持音频",
"audioIsUnavailable": "此视频流不支持音频",
"audio": {
"tips": {
"title": "音频必须从您的摄像头输出并在 go2rtc 中配置此流。",
"document": "阅读文档(英文) "
}
},
"streamMethod": {
"label": "视频流方法",
"method": {
"noStreaming": {
"label": "无视频流",
"desc": "摄像头图像每分钟仅更新一次,不会进行实时视频流播放。"
},
"smartStreaming": {
"label": "智能视频流(推荐)",
"desc": "智能视频流在没有检测到活动时,每分钟更新一次摄像头图像,以节省带宽和资源。当检测到活动时,图像会无缝切换到实时视频流。"
},
"continuousStreaming": {
"label": "持续视频流",
"desc": {
"title": "当摄像头画面在仪表板上可见时,始终为实时视频流,即使未检测到活动。",
"warning": "持续视频流可能会导致高带宽使用和性能问题,请谨慎使用。"
}
}
}
},
"compatibilityMode": {
"label": "兼容模式",
"desc": "仅在摄像头的实时视频流显示颜色伪影,并且图像右侧有一条对角线时启用此选项。"
}
}
}
},
"debug": {
"options": {
"label": "设置",
"title": "选项",
"showOptions": "显示选项",
"hideOptions": "隐藏选项"
},
"boundingBox": "边界框",
"timestamp": "时间戳",
"zones": "区域",
"mask": "遮罩",
"motion": "运动",
"regions": "区域"
}
}

View File

@ -0,0 +1,113 @@
{
"restart": {
"title": "你确定要重启 Frigate?",
"button": "重启",
"restarting": {
"title": "Frigate 正在重启",
"content": "该页面将会在 {{countdown}} 秒后自动刷新。",
"button": "强制刷新"
}
},
"explore": {
"plus": {
"submitToPlus": {
"label": "提交至 Frigate+",
"desc": "您希望避开的地点中的物体不应被视为误报。若将其作为误报提交可能会导致AI模型容易混淆相关物体的识别。"
},
"review": {
"true": {
"label": "为 Frigate Plus 确认此标签",
"true_one": "这是 {{label}}",
"true_other": "这是 {{label}}"
},
"false": {
"label": "不为 Frigate Plus 确认此标签",
"false_one": "这不是 {{label}}",
"false_other": "这不是 {{label}}"
},
"state": {
"submitted": "已提交"
}
}
},
"video": {
"viewInHistory": "在历史中查看"
}
},
"export": {
"time": {
"fromTimeline": "从时间线选择",
"lastHour_one": "最后1小时",
"lastHour_other": "最后 {{count}} 小时",
"custom": "自定义",
"start": {
"title": "开始时间",
"label": "选择开始时间"
},
"end": {
"title": "结束时间",
"label": "选择结束时间"
}
},
"name": {
"placeholder": "导出项目的名字"
},
"select": "选择",
"export": "导出",
"selectOrExport": "选择或导出",
"toast": {
"success": "导出成功。进入 /exports 目录查看文件。",
"error": {
"failed": "导出失败:{{error}}",
"endTimeMustAfterStartTime": "结束时间必须在开始时间之后",
"noVaildTimeSelected": "未选择有效的时间范围"
}
},
"fromTimeline": {
"saveExport": "保存导出",
"previewExport": "预览导出"
}
},
"streaming": {
"label": "视频流",
"restreaming": {
"disabled": "此摄像头未启用视频流转发功能。",
"desc": {
"title": "为此摄像头设置 go2rtc以获取额外的实时预览选项和音频支持。",
"readTheDocumentation": "阅读文档(英文) "
}
},
"showStats": {
"label": "显示视频流统计信息",
"desc": "启用后将在摄像头画面上叠加显示视频流统计信息。"
},
"debugView": "调试界面"
},
"search": {
"saveSearch": {
"label": "保存搜索",
"desc": "请为此已保存的搜索提供一个名称。",
"placeholder": "请输入搜索名称",
"overwrite": "{{searchName}} 已存在。保存将覆盖现有值。",
"success": "搜索 ({{searchName}}) 已保存。",
"button": {
"save": {
"label": "保存此搜索"
}
}
}
},
"recording": {
"confirmDelete": {
"title": "确认删除",
"desc": {
"selected": "您确定要删除与此审核项相关的所有录制视频吗?<br /><br />提示:按住 <em>Shift</em> 键点击删除可跳过此对话框。"
}
},
"button": {
"export": "导出",
"markAsReviewed": "标记为已审核",
"deleteNow": "立即删除"
}
}
}

View File

@ -0,0 +1,124 @@
{
"filter": "过滤器",
"labels": {
"label": "标签",
"all": {
"title": "所有标签",
"short": "标签"
},
"count": "{{count}} 个标签"
},
"zones": {
"all": {
"title": "所有区域",
"short": "区域"
},
"label": "区域"
},
"dates": {
"all": {
"title": "所有日期",
"short": "日期"
}
},
"more": "更多筛选项",
"reset": {
"label": "重置筛选器为默认值"
},
"timeRange": "时间范围",
"subLabels": {
"label": "子标签",
"all": "所有子标签"
},
"score": "分值",
"estimatedSpeed": "预计速度({{unit}}",
"features": {
"label": "特性",
"hasSnapshot": "包含快照",
"hasVideoClip": "包含视频片段",
"submittedToFrigatePlus": {
"label": "提交至 Frigate+",
"tips": "你必须要先筛选具有快照的探测对象。<br /><br />没有快照的跟踪对象无法提交至 Frigate+."
}
},
"sort": {
"label": "排序",
"dateAsc": "日期 (正序)",
"dateDesc": "日期 (倒序)",
"scoreAsc": "对象分值 (正序)",
"scoreDesc": "对象分值 (倒序)",
"speedAsc": "预计速度 (正序)",
"speedDesc": "预计速度 (倒序)",
"relevance": "关联性"
},
"cameras": {
"label": "摄像头筛选",
"all": {
"title": "所有摄像头",
"short": "摄像头"
}
},
"review": {
"showReviewed": "显示已查看的项目"
},
"motion": {
"showMotionOnly": "仅显示运动"
},
"explore": {
"settings": {
"title": "设置",
"defaultView": {
"title": "默认视图",
"desc": "当未选择任何过滤器时,显示每个标签最近跟踪对象的摘要,或显示未过滤的网格。",
"summary": "摘要",
"unfilteredGrid": "未过滤网格"
},
"gridColumns": {
"title": "网格列数",
"desc": "选择网格视图中的列数。"
},
"searchSource": {
"label": "搜索源",
"desc": "选择是搜索缩略图还是跟踪对象的描述。",
"options": {
"thumbnailImage": "缩略图",
"description": "描述"
}
}
},
"date": {
"selectDateBy": {
"label": "选择日期进行筛选"
}
}
},
"logSettings": {
"label": "日志级别筛选",
"filterBySeverity": "按严重程度筛选日志",
"loading": {
"title": "加载中",
"desc": "当日志面板滚动到底部时,新的日志会自动流式加载。"
},
"disableLogStreaming": "禁用日志流式加载",
"allLogs": "所有日志"
},
"trackedObjectDelete": {
"title": "确认删除",
"desc": "删除这 {{objectLength}} 个跟踪对象将移除快照、任何已保存的嵌入和任何相关的对象生命周期条目。历史视图中这些跟踪对象的录制片段将<em>不会</em>被删除。<br /><br />您确定要继续吗?<br /><br />按住 <em>Shift</em> 键可在将来跳过此对话框。",
"toast": {
"success": "跟踪对象删除成功。",
"error": "删除跟踪对象失败:{{errorMessage}}"
}
},
"zoneMask": {
"filterBy": "按区域遮罩筛选"
},
"recognizedLicensePlates": {
"title": "识别的车牌",
"loadFailed": "加载识别的车牌失败。",
"loading": "正在加载识别的车牌...",
"placeholder": "输入以搜索车牌...",
"noLicensePlatesFound": "未找到车牌。",
"selectPlatesFromList": "从列表中选择一个或多个车牌。"
}
}

View File

@ -0,0 +1,8 @@
{
"iconPicker": {
"selectIcon": "选择图标",
"search": {
"placeholder": "搜索图标..."
}
}
}

View File

@ -0,0 +1,10 @@
{
"button": {
"downloadVideo": {
"label": "下载视频",
"toast": {
"success": "您的回放视频已开始下载。"
}
}
}
}

View File

@ -0,0 +1,51 @@
{
"noRecordingsFoundForThisTime": "找不到此次录制",
"noPreviewFound": "没有找到预览",
"noPreviewFoundFor": "没有在 {{cameraName}} 下找到预览",
"submitFrigatePlus": {
"title": "提交此帧到 Frigate+",
"submit": "提交"
},
"livePlayerRequiredIOSVersion": "此直播流类型需要 iOS 17.1 或更高版本。",
"streamOffline": {
"title": "视频流离线",
"desc": "未在 {{cameraName}} 的 <code>detect</code> 流上接收到任何帧,请检查错误日志"
},
"cameraDisabled": "摄像机已禁用",
"stats": {
"streamType": {
"title": "流类型:",
"short": "类型"
},
"bandwidth": {
"title": "带宽:",
"short": "带宽"
},
"latency": {
"title": "延迟:",
"value": "{{seconds}} 秒",
"short": {
"title": "延迟",
"value": "{{seconds}} 秒"
}
},
"totalFrames": "总帧数:",
"droppedFrames": {
"title": "丢帧数:",
"short": {
"title": "丢帧",
"value": "{{droppedFrames}} 帧"
}
},
"decodedFrames": "解码帧数:",
"droppedFrameRate": "丢帧率:"
},
"toast": {
"success": {
"submittedFrigatePlus": "已成功提交帧到 Frigate+"
},
"error": {
"submitFrigatePlusFailed": "提交帧到 Frigate+ 失败"
}
}
}

View File

@ -0,0 +1,120 @@
{
"person": "人",
"bicycle": "自行车",
"car": "汽车",
"motorcycle": "摩托车",
"airplane": "飞机",
"bus": "公交车",
"train": "火车",
"boat": "船",
"traffic_light": "交通灯",
"fire_hydrant": "消防栓",
"street_sign": "路标",
"stop_sign": "停车标志",
"parking_meter": "停车计时器",
"bench": "长椅",
"bird": "鸟",
"cat": "猫",
"dog": "狗",
"horse": "马",
"sheep": "羊",
"cow": "牛",
"elephant": "大象",
"bear": "熊",
"zebra": "斑马",
"giraffe": "长颈鹿",
"hat": "帽子",
"backpack": "背包",
"umbrella": "雨伞",
"shoe": "鞋子",
"eye_glasses": "眼镜",
"handbag": "手提包",
"tie": "领带",
"suitcase": "手提箱",
"frisbee": "飞盘",
"skis": "滑雪板",
"snowboard": "滑雪板",
"sports_ball": "运动球",
"kite": "风筝",
"baseball_bat": "棒球棒",
"baseball_glove": "棒球手套",
"skateboard": "滑板",
"surfboard": "冲浪板",
"tennis_racket": "网球拍",
"bottle": "瓶子",
"plate": "盘子",
"wine_glass": "酒杯",
"cup": "杯子",
"fork": "叉子",
"knife": "刀",
"spoon": "勺子",
"bowl": "碗",
"banana": "香蕉",
"apple": "苹果",
"sandwich": "三明治",
"orange": "橙子",
"broccoli": "西兰花",
"carrot": "胡萝卜",
"hot_dog": "热狗",
"pizza": "披萨",
"donut": "甜甜圈",
"cake": "蛋糕",
"chair": "椅子",
"couch": "沙发",
"potted_plant": "盆栽植物",
"bed": "床",
"mirror": "镜子",
"dining_table": "餐桌",
"window": "窗户",
"desk": "桌子",
"toilet": "厕所",
"door": "门",
"tv": "电视",
"laptop": "笔记本电脑",
"mouse": "鼠标",
"remote": "遥控器",
"keyboard": "键盘",
"cell_phone": "手机",
"microwave": "微波炉",
"oven": "烤箱",
"toaster": "烤面包机",
"sink": "水槽",
"refrigerator": "冰箱",
"blender": "搅拌机",
"book": "书",
"clock": "时钟",
"vase": "花瓶",
"scissors": "剪刀",
"teddy_bear": "泰迪熊",
"hair_dryer": "吹风机",
"toothbrush": "牙刷",
"hair_brush": "发刷",
"vehicle": "车辆",
"squirrel": "松鼠",
"deer": "鹿",
"animal": "动物",
"bark": "树皮",
"fox": "狐狸",
"goat": "山羊",
"rabbit": "兔子",
"raccoon": "浣熊",
"robot_lawnmower": "自动割草机",
"waste_bin": "垃圾桶",
"on_demand": "手动",
"face": "人脸",
"license_plate": "车牌",
"package": "包裹",
"bbq_grill": "烧烤架",
"amazon": "亚马逊",
"usps": "美国邮政",
"ups": "UPS",
"fedex": "联邦快递",
"dhl": "DHL",
"an_post": "爱尔兰邮政",
"purolator": "普罗莱特",
"postnl": "荷兰邮政",
"nzpost": "新西兰邮政",
"postnord": "北欧邮政",
"gls": "GLS",
"dpd": "DPD"
}

View File

@ -0,0 +1,15 @@
{
"documentTitle": "配置编辑器 - Frigate",
"configEditor": "配置编辑器",
"copyConfig": "复制配置",
"saveAndRestart": "保存并重启",
"saveOnly": "只保存",
"toast": {
"success": {
"copyToClipboard": "配置已复制到剪贴板。"
},
"error": {
"savingError": "保存配置时出错"
}
}
}

View File

@ -0,0 +1,35 @@
{
"alerts": "警告",
"detections": "检测",
"motion": {
"label": "运动",
"only": "仅运动画面"
},
"allCameras": "所有摄像头",
"empty": {
"alert": "还没有“警告”类回放",
"detection": "还没有“探测”类回放",
"motion": "还没有运动类数据"
},
"timeline": "时间线",
"timeline.aria": "选择时间线",
"events": {
"label": "事件",
"aria": "选择事件",
"noFoundForTimePeriod": "未找到该时间段的事件。"
},
"documentTitle": "预览 - Frigate",
"recordings": {
"documentTitle": "回放 - Frigate"
},
"calendarFilter": {
"last24Hours": "过去24小时"
},
"markAsReviewed": "标记为已审核",
"markTheseItemsAsReviewed": "将这些项目标记为已审核",
"newReviewItems": {
"label": "查看新的审核项目",
"button": "新的待审核项目"
},
"camera": "摄像头"
}

View File

@ -0,0 +1,183 @@
{
"documentTitle": "探索 - Frigate",
"generativeAI": "生成式 AI",
"exploreIsUnavailable": {
"title": "探索功能不可用",
"embeddingsReindexing": {
"context": "跟踪对象嵌入重新索引完成后,可以使用探索功能。",
"startingUp": "启动中...",
"estimatedTime": "预计剩余时间:",
"finishingShortly": "即将完成",
"step": {
"thumbnailsEmbedded": "缩略图嵌入:",
"descriptionsEmbedded": "描述嵌入:",
"trackedObjectsProcessed": "跟踪对象已处理:"
}
},
"downloadingModels": {
"context": "Frigate正在下载支持语义搜索功能所需的嵌入模型。根据网络连接速度这可能需要几分钟。",
"setup": {
"visionModel": "视觉模型",
"visionModelFeatureExtractor": "视觉模型特征提取器",
"textModel": "文本模型",
"textTokenizer": "文本分词器"
},
"tips": {
"context": "模型下载完成后,您可能需要重新索引跟踪对象的嵌入。",
"documentation": "阅读文档(英文)"
},
"error": "发生错误。请检查Frigate日志。"
}
},
"trackedObjectDetails": "跟踪对象详情",
"type": {
"details": "详情",
"snapshot": "快照",
"video": "视频",
"object_lifecycle": "对象生命周期"
},
"objectLifecycle": {
"title": "对象生命周期",
"noImageFound": "未找到此时间戳的图像。",
"createObjectMask": "创建对象遮罩",
"adjustAnnotationSettings": "调整标注设置",
"scrollViewTips": "滚动查看此对象生命周期的重要时刻。",
"autoTrackingTips": "自动跟踪摄像头的边界框位置可能不准确。",
"lifecycleItemDesc": {
"visible": "检测到 {{label}}",
"entered_zone": "{{label}} 进入 {{zones}}",
"active": "{{label}} 变为活动状态",
"stationary": "{{label}} 变为静止状态",
"attribute": {
"faceOrLicense_plate": "检测到 {{label}} 的 {{attribute}}",
"other": "{{label}} 识别为 {{attribute}}"
},
"gone": "{{label}} 离开",
"heard": "听到 {{label}}",
"external": "检测到 {{label}}"
},
"annotationSettings": {
"title": "标注设置",
"showAllZones": {
"title": "显示所有区域",
"desc": "在对象进入区域的帧上始终显示区域。"
},
"offset": {
"label": "标注偏移",
"desc": "这些数据来自摄像头的检测源,但是叠加在录制源的图像上。这两个流不太可能完全同步。因此,边界框和录像不会完全对齐。但是,可以使用 <code>annotation_offset</code> 字段来调整这个问题。",
"documentation": "阅读文档(英文) ",
"millisecondsToOffset": "检测标注的偏移毫秒数。<em>默认值0</em>",
"tips": "提示:假设有一个人从左向右走的事件片段。如果事件时间线上的边界框始终在人的左侧,则应该减小该值。同样,如果一个人从左向右走,而边界框始终在人的前面,则应该增加该值。"
}
},
"carousel": {
"previous": "上一张",
"next": "下一张"
}
},
"details": {
"item": {
"title": "回放项目详情",
"desc": "回放项目详情",
"button": {
"share": "分享该回放",
"viewInExplore": "在探测中查看"
},
"tips": {
"mismatch_one": "检测到 {{count}} 个不可用的对象,并已包含在此审核项中。这些对象可能未达到警告或检测标准,或者已被清理/删除。",
"mismatch_other": "检测到 {{count}} 个不可用的对象,并已包含在此审核项中。这些对象可能未达到警告或检测标准,或者已被清理/删除。",
"hasMissingObjects": "如果希望 Frigate 保存以下标签的跟踪对象,请调整您的配置:<em>{{objects}}</em>"
},
"toast": {
"success": {
"regenerate": "已向 {{provider}} 请求新的描述。根据提供商的速度,生成新描述可能需要一些时间。",
"updatedSublabel": "成功更新子标签。"
},
"error": {
"regenerate": "调用 {{provider}} 生成新描述失败:{{errorMessage}}",
"updatedSublabelFailed": "更新子标签失败:{{errorMessage}}"
}
}
},
"label": "标签",
"editSubLabel": {
"title": "编辑子标签",
"desc": "为 {{label}} 输入新的子标签",
"descNoLabel": "为此跟踪对象输入新的子标签"
},
"topScore": {
"label": "最高得分",
"info": "最高分是跟踪对象的最高中位数得分,因此可能与搜索结果缩略图上显示的得分不同。"
},
"estimatedSpeed": "预计速度",
"objects": "对象",
"camera": "摄像头",
"zones": "区域",
"timestamp": "时间",
"button": {
"findSimilar": "查找相似项",
"regenerate": {
"title": "重新生成",
"label": "重新生成跟踪对象描述"
}
},
"description": {
"label": "描述",
"placeholder": "跟踪对象的描述",
"aiTips": "在跟踪对象的生命周期结束之前Frigate 不会向您的生成式 AI 提供商请求描述。"
},
"expandRegenerationMenu": "展开重新生成菜单",
"regenerateFromSnapshot": "从快照重新生成",
"regenerateFromThumbnails": "从缩略图重新生成",
"tips": {
"descriptionSaved": "已保存描述",
"saveDescriptionFailed": "更新描述失败:{{errorMessage}}"
}
},
"itemMenu": {
"downloadVideo": {
"label": "下载视频",
"aria": "下载视频"
},
"downloadSnapshot": {
"label": "下载快照",
"aria": "下载快照"
},
"viewObjectLifecycle": {
"label": "查看对象生命周期",
"aria": "显示对象的生命周期"
},
"findSimilar": {
"label": "查找相似项",
"aria": "查看相似的对象"
},
"submitToPlus": {
"label": "提交至 Frigate+",
"aria": "提交至 Frigate Plus"
},
"viewInHistory": {
"label": "在历史记录中查看",
"aria": "在历史记录中查看"
},
"deleteTrackedObject": {
"label": "删除此跟踪对象"
}
},
"dialog": {
"confirmDelete": {
"title": "确认删除",
"desc": "删除此跟踪对象将移除快照、所有已保存的嵌入数据以及任何关联的对象生命周期条目。但在历史视图中的录制视频<em>不会</em>被删除。<br /><br />你确定要继续删除吗?"
}
},
"noTrackedObjects": "未找到跟踪对象",
"fetchingTrackedObjectsFailed": "获取跟踪对象失败:{{errorMessage}}",
"trackedObjectsCount": "{{count}} 个跟踪对象",
"searchResult": {
"deleteTrackedObject": {
"toast": {
"success": "跟踪对象删除成功。",
"error": "删除跟踪对象失败:{{errorMessage}}"
}
}
}
}

View File

@ -0,0 +1,17 @@
{
"documentTitle": "导出 - Frigate",
"search": "搜索",
"noExports": "没有找到导出的项目",
"deleteExport": "删除导出的项目",
"deleteExport.desc": "你确定要删除 {{exportName}} 吗?",
"editExport": {
"title": "重命名导出",
"desc": "为此导出项目输入新名称。",
"saveExport": "保存导出"
},
"toast": {
"error": {
"renameExportFailed": "重命名导出失败:{{errorMessage}}"
}
}
}

View File

@ -0,0 +1,51 @@
{
"description": {
"addFace": "我们将指导如何将新面孔添加到人脸库中。"
},
"details": {
"confidence": "置信度",
"face": "人脸详情",
"faceDesc": "人脸及相关对象的详细信息",
"timestamp": "时间戳"
},
"documentTitle": "人脸库 - Frigate",
"uploadFaceImage": {
"title": "上传人脸图片",
"desc": "上传图片以扫描人脸并包含在{{pageToggle}}中"
},
"createFaceLibrary": {
"title": "创建人脸库",
"desc": "创建一个新的人脸库",
"nextSteps": "建议使用“训练”选项卡为每个检测到的人选择并训练图像。在打好基础前,强烈建议训练仅使用正面图像。而不是从摄像机中识别到的角度拍摄的人脸图像。"
},
"train": {
"title": "训练",
"aria": "选择训练"
},
"selectItem": "选择{{item}}",
"button": {
"deleteFaceAttempts": "尝试删除人脸",
"addFace": "添加人脸",
"uploadImage": "上传图片",
"reprocessFace": "重新处理人脸"
},
"readTheDocs": "阅读文档查看更多有关为人脸库优化图像的详细信息",
"trainFaceAs": "将人脸训练为:",
"trainFaceAsPerson": "将人脸训练为人物",
"toast": {
"success": {
"uploadedImage": "图片上传成功。",
"addFaceLibrary": "{{name}} 成功添加至人脸库。",
"deletedFace": "人脸删除成功。",
"trainedFace": "人脸训练成功。",
"updatedFaceScore": "人脸分数更新成功。"
},
"error": {
"uploadingImageFailed": "图片上传失败:{{errorMessage}}",
"addFaceLibraryFailed": "设置人脸名称失败:{{errorMessage}}",
"deleteFaceFailed": "删除失败:{{errorMessage}}",
"trainFailed": "训练失败:{{errorMessage}}",
"updateFaceScoreFailed": "更新人脸分数失败:{{errorMessage}}"
}
}
}

View File

@ -0,0 +1,158 @@
{
"documentTitle": "实时监控 - Frigate",
"documentTitle.withCamera": "{{camera}} - 实时监控 - Frigate",
"lowBandwidthMode": "低带宽模式",
"twoWayTalk": {
"enable": "开启双向对话",
"disable": "关闭双向对话"
},
"cameraAudio": {
"enable": "开启摄像头音频",
"disable": "关闭摄像头音频"
},
"ptz": {
"move": {
"clickMove": {
"label": "点击画面以使摄像头居中",
"enable": "启用点击移动",
"disable": "禁用点击移动"
},
"left": {
"label": "PTZ摄像头向左移动"
},
"up": {
"label": "PTZ摄像头向上移动"
},
"down": {
"label": "PTZ摄像头向下移动"
},
"right": {
"label": "PTZ摄像头向右移动"
}
},
"zoom": {
"in": {
"label": "PTZ摄像头放大"
},
"out": {
"label": "PTZ摄像头缩小"
}
},
"frame": {
"center": {
"label": "点击将PTZ摄像头画面居中"
}
},
"presets": "PTZ摄像头预设"
},
"camera": {
"enable": "开启摄像头",
"disable": "关闭摄像头"
},
"muteCameras": {
"enable": "屏蔽所有摄像头",
"disable": "取消屏蔽所有摄像头"
},
"detect": {
"enable": "启用检测",
"disable": "关闭检测"
},
"recording": {
"enable": "启用录制",
"disable": "关闭录制"
},
"snapshots": {
"enable": "启用快照",
"disable": "关闭快照"
},
"audioDetect": {
"enable": "启用音频检测",
"disable": "关闭音频检测"
},
"autotracking": {
"enable": "启用自动追踪",
"disable": "关闭自动追踪"
},
"streamStats": {
"enable": "显示视频流统计信息",
"disable": "隐藏视频流统计信息"
},
"manualRecording": {
"title": "按需录制",
"tips": "根据此摄像头的录制保留设置,手动启动事件。",
"playInBackground": {
"label": "后台播放",
"desc": "启用此选项可在播放器隐藏时继续视频流播放。"
},
"showStats": {
"label": "显示统计信息",
"desc": "启用此选项可在摄像头画面上叠加显示视频流统计信息。"
},
"debugView": "调试视图",
"start": "开始手动按需录制",
"started": "已启用手动按需录制",
"failedToStart": "启动手动录制失败",
"recordDisabledTips": "由于此摄像头的配置中禁用了录制或对其进行了限制,将只会保存快照。",
"end": "停止手动按需录制",
"ended": "已完成手动按需录制",
"failedToEnd": "停止手动录制失败"
},
"streamingSettings": "视频流设置",
"notifications": "通知",
"audio": "音频",
"suspend": {
"forTime": "暂停时长:"
},
"stream": {
"title": "视频流",
"audio": {
"tips": {
"title": "音频必须从摄像头输出并在 go2rtc 中配置为此视频流使用。",
"documentation": "阅读文档 "
},
"available": "此视频流支持音频",
"unavailable": "此视频流不支持音频"
},
"twoWayTalk": {
"tips": "您的设备必须支持此功能,并且必须配置 WebRTC 以支持双向对讲。",
"tips.documentation": "阅读文档 ",
"available": "此视频流支持双向对讲",
"unavailable": "此视频流不支持双向对讲"
},
"lowBandwidth": {
"tips": "由于缓冲或视频流错误,实时视图处于低带宽模式。",
"resetStream": "重置视频流"
},
"playInBackground": {
"label": "后台播放",
"tips": "启用此选项可在播放器隐藏时继续视频流播放。"
}
},
"cameraSettings": {
"title": "{{camera}} 设置",
"cameraEnabled": "摄像头已启用",
"objectDetection": "对象检测",
"recording": "录制",
"snapshots": "快照",
"audioDetection": "音频检测",
"autotracking": "自动跟踪"
},
"history": {
"label": "显示历史录像"
},
"effectiveRetainMode": {
"modes": {
"all": "全部",
"motion": "运动",
"active_objects": "活动对象"
},
"notAllTips": "您的 {{source}} 录制保留配置设置为 <code>mode: {{effectiveRetainMode}}</code>,因此此按需录制将仅保留包含 {{effectiveRetainModeName}} 的片段。"
},
"editLayout": {
"label": "编辑布局",
"group": {
"label": "编辑摄像机分组"
},
"exitEdit": "退出编辑"
}
}

View File

@ -0,0 +1,12 @@
{
"export": "导出",
"calendar": "日历",
"filter": "筛选",
"filters": "筛选条件",
"toast": {
"error": {
"noValidTimeSelected": "未选择有效的时间范围",
"endTimeMustAfterStartTime": "结束时间必须晚于开始时间"
}
}
}

View File

@ -0,0 +1,67 @@
{
"search": "搜索",
"savedSearches": "已保存的搜索",
"searchFor": "搜索 {{inputValue}}",
"button": {
"clear": "清除搜索",
"save": "保存搜索",
"delete": "删除已保存的搜索",
"filterInformation": "筛选信息",
"filterActive": "筛选器已激活"
},
"trackedObjectId": "跟踪对象 ID",
"filter": {
"label": {
"cameras": "摄像机",
"labels": "标签",
"zones": "区域",
"sub_labels": "子标签",
"search_type": "搜索类型",
"time_range": "时间范围",
"before": "之前",
"after": "之后",
"min_score": "最低分数",
"max_score": "最高分数",
"min_speed": "最低速度",
"max_speed": "最高速度",
"recognized_license_plate": "识别的车牌",
"has_clip": "包含片段",
"has_snapshot": "包含快照"
},
"searchType": {
"thumbnail": "缩略图",
"description": "描述"
},
"toast": {
"error": {
"beforeDateBeLaterAfter": "结束日期必须晚于开始日期。",
"afterDatebeEarlierBefore": "开始日期必须早于结束日期。",
"minScoreMustBeLessOrEqualMaxScore": "最低分数必须小于或等于最高分数。",
"maxScoreMustBeGreaterOrEqualMinScore": "最高分数必须大于或等于最低分数。",
"minSpeedMustBeLessOrEqualMaxSpeed": "最低速度必须小于或等于最高速度。",
"maxSpeedMustBeGreaterOrEqualMinSpeed": "最高速度必须大于或等于最低速度。"
}
},
"tips": {
"title": "如何使用文本筛选器(英文)",
"desc": {
"text": "筛选器可帮助您缩小搜索范围。注意,目前还暂不支持中文搜索。以下是在输入字段中使用筛选器的方法:",
"step": "<ul className=\"list-disc pl-5 text-sm text-primary-variant\"><li>输入筛选器名称后跟一个冒号例如“cameras:”)。</li><li>从建议中选择一个值或输入您自己的值。</li><li>使用多个筛选器时,可以在它们之间用空格分隔。</li><li>日期筛选器before: 和 after:)使用 <em>{{DateFormat}}</em> 格式。</li><li>时间范围筛选器使用 <em>{{exampleTime}}</em> 格式。</li><li>点击筛选器旁边的“x”即可移除筛选条件。</li></ul>",
"example": "示例:<code className=\"text-primary\">cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM</code>"
}
},
"header": {
"currentFilterType": "筛选值",
"noFilters": "筛选条件",
"activeFilters": "激活的筛选项"
}
},
"similaritySearch": {
"title": "相似搜索",
"active": "相似搜索已激活",
"clear": "清除相似搜索"
},
"placeholder": {
"search": "搜索..."
}
}

View File

@ -0,0 +1,550 @@
{
"documentTitle": {
"default": "设置 - Frigate",
"authentication": "身份验证设置 - Frigate",
"camera": "摄像头设置 - Frigate",
"classification": "分类设置 - Frigate",
"masksAndZones": "遮罩和区域编辑器 - Frigate",
"motionTuner": "运动调整器 - Frigate",
"object": "对象设置 - Frigate",
"general": "常规设置 - Frigate"
},
"dialog": {
"unsavedChanges": {
"title": "你有未保存的更改。",
"desc": "是否要在继续之前保存更改?"
}
},
"menu": {
"uiSettings": "界面设置",
"classificationSettings": "分类设置",
"cameraSettings": "摄像头设置",
"masksAndZones": "遮罩/ 区域",
"motionTuner": "运动调整器",
"debug": "调试",
"users": "用户",
"notifications": "通知"
},
"cameraSetting": {
"camera": "摄像头",
"noCamera": "没有摄像头"
},
"general": {
"title": "常规设置",
"liveDashboard": {
"title": "实时监控面板",
"automaticLiveView": {
"label": "自动实时预览",
"desc": "检测到画面活动时将自动切换至该摄像头实时画面。禁用此选项会导致实时监控页面的摄像头图像每分钟只更新一次。"
},
"playAlertVideos": {
"label": "播放警告视频",
"desc": "默认情况下,实时监控页面上的最新警告会以一小段循环的形式进行播放。禁用此选项将仅显示浏览器本地缓存的静态图片。"
}
},
"storedLayouts": {
"title": "存储监控面板布局",
"desc": "可以在监控面板调整或拖动摄像头的布局。这些设置将保存在浏览器的本地存储中。",
"clearAll": "清除所有布局"
},
"cameraGroupStreaming": {
"title": "摄像头组视频流设置",
"desc": "每个摄像头组的视频流设置将保存在浏览器的本地存储中。",
"clearAll": "清除所有视频流设置"
},
"recordingsViewer": {
"title": "回放查看",
"defaultPlaybackRate": {
"label": "默认播放速率",
"desc": "调整播放录像时默认的速率。"
}
},
"calendar": {
"title": "日历",
"firstWeekday": {
"label": "每周第一天",
"desc": "设置每周第一天是星期几。",
"sunday": "星期天",
"monday": "星期一"
}
},
"toast": {
"success": {
"clearStoredLayout": "已清除 {{cameraName}} 的存储布局",
"clearStreamingSettings": "已清除所有摄像头组的视频流设置。"
},
"error": {
"clearStoredLayoutFailed": "清除存储布局失败:{{errorMessage}}",
"clearStreamingSettingsFailed": "清除视频流设置失败:{{errorMessage}}"
}
}
},
"classification": {
"title": "分类设置",
"semanticSearch": {
"title": "语义搜索",
"desc": "Frigate的语义搜索能够让你使用自然语言根据图像本身、自定义的文本描述或自动生成的描述来搜索视频。",
"readTheDocumentation": "阅读文档(英文)",
"reindexOnStartup": {
"label": "启动时重新索引",
"desc": "每次启动将重新索引并重新处理所有缩略图和描述。<em>关闭该设置后不要忘记重启!</em>"
},
"modelSize": {
"label": "模型大小",
"desc": "用于语义搜索的语言模型大小",
"small": {
"title": "小",
"desc": "使用 <strong>小</strong>模型。该模型将使用较少的内存在CPU上也能较快的运行。质量较好。"
},
"large": {
"title": "大",
"desc": "使用 <strong>大</strong>模型。该模型采用了完整的Jina模型并在适用的情况下使用GPU。"
}
}
},
"faceRecognition": {
"title": "人脸识别",
"desc": "人脸识别功能允许为人物分配名称当识别到他们的面孔时Frigate 会将人物的名字作为子标签进行分配。这些信息会显示在界面、过滤器以及通知中。",
"readTheDocumentation": "阅读文档(英文)"
},
"licensePlateRecognition": {
"title": "车牌识别",
"desc": "Frigate 可以识别车辆的车牌,并自动将检测到的字符添加到 recognized_license_plate 字段中,或将已知名称作为子标签添加到汽车类型的对象中。常见的使用场景可能是读取驶入车道的汽车车牌或经过街道的汽车车牌。",
"readTheDocumentation": "阅读文档(英文)"
},
"toast": {
"success": "分类设置已保存。",
"error": "保存配置更改失败:{{errorMessage}}"
}
},
"camera": {
"title": "摄像头设置",
"streams": {
"title": "视频流",
"desc": "禁用摄像头将完全停止 Frigate 对该摄像头视频流的处理。检测、录制和调试功能都将不可用。<br /><em>注意:该选项不会禁用 go2rtc 转播。</em>"
},
"review": {
"title": "预览",
"desc": "启用/禁用摄像头的警报和检测。禁用后,不会生成新的预览项。",
"alerts": "警告 ",
"detections": "检测 "
},
"reviewClassification": {
"title": "预览分级",
"desc": "Frigate 将回放项目分为“警告”和“检测”。默认情况下,所有的 <em>人</em>、<em>汽车</em> 的对象都视为警告。你可以通过修改配置文件配置区域来细分。",
"readTheDocumentation": "阅读文档(英文)",
"noDefinedZones": "该摄像头没有设置区域。",
"objectAlertsTips": "所有的 {{alertsLabels}} 对象在 {{cameraName}} 都将显示为警告。",
"zoneObjectAlertsTips": "所有的 {{alertsLabels}} 对象在 {{cameraName}} 的 {{zone}} 区域都将显示为警告。",
"objectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
"zoneObjectDetectionsTips": {
"text": "所有未在 {{cameraName}} 上归类为 {{detectionsLabels}} 的对象在 {{zone}} 区域都将显示为检测。",
"notSelectDetections": "所有在 {{cameraName}} 的 {{zone}} 上检测到的未归类为警告的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。",
"regardlessOfZoneObjectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。"
},
"selectAlertsZones": "选择要显示为警告的区域",
"selectDetectionsZones": "选择检测区域",
"limitDetections": "限制仅在特定区域内进行检测",
"toast": {
"success": "预览分级配置已保存。请重启 Frigate 以应用更改。"
}
}
},
"masksAndZones": {
"filter": {
"all": "所有遮罩和区域"
},
"toast": {
"success": {
"copyCoordinates": "已复制 {{polyName}} 的坐标到剪贴板。"
},
"error": {
"copyCoordinatesFailed": "无法复制坐标到剪贴板。"
}
},
"form": {
"zoneName": {
"error": {
"mustBeAtLeastTwoCharacters": "区域名称必须至少包含 2 个字符。",
"mustNotBeSameWithCamera": "区域名称不能与摄像头名称相同。",
"alreadyExists": "该摄像头已有相同的区域名称。",
"mustNotContainPeriod": "区域名称不能包含句点。",
"hasIllegalCharacter": "区域名称包含非法字符。"
}
},
"distance": {
"error": {
"text": "距离必须大于或等于 0.1。",
"mustBeFilled": "所有距离字段必须填写才能使用速度估算。"
}
},
"inertia": {
"error": {
"mustBeAboveZero": "惯性必须大于 0。"
}
},
"loiteringTime": {
"error": {
"mustBeGreaterOrEqualZero": "徘徊时间必须大于或等于 0。"
}
},
"polygonDrawing": {
"removeLastPoint": "删除最后一个点",
"reset": {
"label": "清除所有点"
},
"snapPoints": {
"true": "启用点对齐",
"false": "禁用点对齐"
},
"delete": {
"title": "确认删除",
"desc": "你确定要删除{{type}} <em>{{name}}</em> 吗?",
"success": "{{name}} 已被删除。"
},
"error": {
"mustBeFinished": "多边形绘制必须完成闭合后才能保存。"
}
}
},
"zones": {
"label": "区域",
"documentTitle": "编辑区域 - Frigate",
"desc": {
"title": "该功能允许你定义特定区域,以便你可以确定特定对象是否在该区域内。",
"documentation": "文档(英文)"
},
"add": "添加区域",
"edit": "编辑区域",
"point_one": "{{count}} 点",
"point_other": "{{count}} 点",
"clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
"name": {
"title": "区域名称",
"inputPlaceHolder": "请输入名称",
"tips": "名称至少包含两个字符,且不能和摄像头或其他区域同名。<br>当前仅支持英文与数字组合"
},
"inertia": {
"title": "惯性",
"desc": "识别指定对象前该对象必须在这个区域内出现了多少帧。<em>默认值3</em>"
},
"loiteringTime": {
"title": "停留时间",
"desc": "设置对象必须在区域中活动的最小时间(单位为秒)。<em>默认值0</em>"
},
"objects": {
"title": "对象",
"desc": "将在此区域应用的对象列表。"
},
"allObjects": "所有对象",
"speedEstimation": {
"title": "速度估算",
"desc": "启用此区域内物体的速度估算。该区域必须恰好包含 4 个点。"
},
"speedThreshold": {
"title": "速度阈值 ({{unit}})",
"desc": "指定物体在此区域内被视为有效的最低速度。",
"toast": {
"error": {
"pointLengthError": "此区域的速度估算已禁用。启用速度估算的区域必须恰好包含 4 个点。",
"loiteringTimeError": "徘徊时间大于 0 的区域不应与速度估算一起使用。"
}
}
},
"toast": {
"success": "区域 ({{zoneName}}) 已保存。请重启 Frigate 以应用更改。"
}
},
"motionMasks": {
"label": "运动遮罩",
"documentTitle": "编辑运动遮罩 - Frigate",
"desc": {
"title": "运动遮罩用于防止触发不必要的运动类型。过度的设置遮罩将使对象更加难以被追踪",
"documentation": "文档(英文)"
},
"add": "添加运动遮罩",
"edit": "编辑运动遮罩",
"context": {
"title": "运动遮罩用于防止不需要的运动类型触发检测(例如:树枝、摄像头显示的时间等)。运动遮罩需要<strong>谨慎使用</strong>,过度的遮罩会导致追踪对象变得更加困难。",
"documentation": "阅读文档(英文)"
},
"point_one": "{{count}} 点",
"point_other": "{{count}} 点",
"clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
"polygonAreaTooLarge": {
"title": "运动遮罩的大小达到了摄像头画面的{{polygonArea}}%。不建议设置太大的运动遮罩。",
"tips": "运动遮罩不会阻止检测到对象,你应该使用区域来限制检测对象。",
"documentation": "阅读文档(英文)"
},
"toast": {
"success": {
"title": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
"noName": "运动遮罩已保存。请重启 Frigate 以应用更改。"
}
}
},
"objectMasks": {
"label": "对象遮罩",
"documentTitle": "编辑对象遮罩 - Frigate",
"desc": {
"title": "对象过滤器用于防止特定位置的指定对象被误报。",
"documentation": "文档(英文)"
},
"add": "添加对象遮罩",
"edit": "编辑对象遮罩",
"context": "对象过滤器用于防止特定位置的指定对象被误报。",
"point_one": "{{count}} 点",
"point_other": "{{count}} 点",
"clickDrawPolygon": "在图像上点击添加点绘制多边形区域。",
"objects": {
"title": "对象",
"desc": "将应用于此对象遮罩的对象列表。",
"allObjectTypes": "所有对象类型"
},
"toast": {
"success": {
"title": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。",
"noName": "对象遮罩已保存。请重启 Frigate 以应用更改。"
}
}
}
},
"motionDetectionTuner": {
"title": "运动检测调整器",
"desc": {
"title": "Frigate 将使用运动检测作为首个步骤,以确认一帧画面中是否有对象需要使用对象检测。",
"documentation": "阅读有关运动检测的文档(英文)"
},
"Threshold": {
"title": "阈值",
"desc": "阈值决定像素亮度高于多少时会被认为是运动。<em>默认值30</em>"
},
"contourArea": {
"title": "轮廓面积",
"desc": "轮廓面积决定哪些变化的像素组符合运动条件。<em>默认值10</em>"
},
"improveContrast": {
"title": "提高对比度",
"desc": "提高较暗场景的对比度。<em>默认值:启用</em>"
},
"toast": {
"success": "运动设置已保存。"
}
},
"debug": {
"title": "调试",
"detectorDesc": "Frigate 将使用探测器({{detectors}})来检测摄像头视频流中的对象。",
"desc": "调试界面将实时显示被追踪的对象以及统计信息,对象列表将显示检测到的对象和延迟显示的概览。",
"debugging": "调试选项",
"objectList": "对象列表",
"noObjects": "没有对象",
"boundingBoxes": {
"title": "边界框",
"desc": "将在被追踪的对象周围显示边界框",
"colors": {
"label": "对象边界框颜色定义",
"info": "<li>启用后,将会为每个对象标签分配不同的颜色</li><li>深蓝色细线代表该对象在当前时间点未被检测到</li><li>灰色细线代表检测到的物体静止不动</li><li>粗线表示该对象为自动跟踪的主体(在启动时)</li>"
}
},
"timestamp": {
"title": "时间戳",
"desc": "在图像上显示时间戳"
},
"zones": {
"title": "区域",
"desc": "显示已定义的区域图层"
},
"mask": {
"title": "运动遮罩",
"desc": "显示运动遮罩图层"
},
"motion": {
"title": "运动区域框",
"desc": "在检测到运动的区域显示区域框",
"tips": "<p className=\"mb-2\"><strong>运动区域框</strong></p><br><p>将在当前检测到运动的区域内显示红色区域框。</p>"
},
"regions": {
"title": "范围",
"desc": "显示发送到运动检测器感兴趣范围的框。",
"tips": "<p className=\"mb-2\"><strong>范围框</strong></p><br><p>将在帧中发送到目标检测器的感兴趣范围上叠加绿色框。</p>"
},
"objectShapeFilterDrawing": {
"title": "允许绘制“对象形状过滤器”",
"desc": "在图像上绘制矩形,以查看区域和比例详细信息。",
"tips": "启用此选项,能够在摄像头图像上绘制矩形,将显示其区域和比例。然后,您可以使用这些值在配置中设置对象形状过滤器参数。",
"document": "阅读文档(英文)",
"score": "分数",
"ratio": "比例",
"area": "区域"
}
},
"users": {
"title": "用户",
"management": {
"title": "用户管理",
"desc": "管理此 Frigate 实例的用户账户。"
},
"addUser": "添加用户",
"updatePassword": "修改密码",
"toast": {
"success": {
"createUser": "用户 {{user}} 创建成功",
"deleteUser": "用户 {{user}} 删除成功",
"updatePassword": "已成功修改密码",
"roleUpdated": "已更新 {{user}} 的权限组"
},
"error": {
"setPasswordFailed": "保存密码出现错误:{{errorMessage}}",
"createUserFailed": "创建用户失败:{{errorMessage}}",
"deleteUserFailed": "删除用户失败:{{errorMessage}}",
"roleUpdateFailed": "更新权限组失败:{{errorMessage}}"
}
},
"table": {
"username": "用户名",
"actions": "操作",
"role": "权限组",
"noUsers": "未找到用户。",
"changeRole": "更改用户角色",
"password": "密码",
"deleteUser": "删除用户"
},
"dialog": {
"form": {
"user": {
"title": "用户名",
"desc": "仅允许使用字母、数字、句点和下划线。",
"placeholder": "请输入用户名"
},
"password": {
"title": "密码",
"placeholder": "请输入密码",
"confirm": {
"title": "确认密码",
"placeholder": "请再次输入密码"
},
"strength": {
"title": "密码强度:",
"weak": "弱",
"medium": "中等",
"strong": "强",
"veryStrong": "非常强"
},
"match": "密码匹配",
"notMatch": "密码不匹配"
},
"newPassword": {
"title": "新密码",
"placeholder": "请输入新密码",
"confirm": {
"placeholder": "请再次输入新密码"
}
},
"usernameIsRequired": "用户名为必填项"
},
"createUser": {
"title": "创建新用户",
"desc": "创建一个新用户账户,并指定一个角色以控制访问 Frigate UI 的权限。",
"usernameOnlyInclude": "用户名只能包含字母、数字和 _"
},
"deleteUser": {
"title": "删除该用户",
"desc": "此操作无法撤销。这将永久删除用户账户并移除所有相关数据。",
"warn": "你确定要删除 <span className=\"font-bold\">{{username}}</span> 吗?"
},
"passwordSetting": {
"updatePassword": "更新 {{username}} 的密码",
"setPassword": "设置密码",
"desc": "创建一个强密码来保护此账户。"
},
"changeRole": {
"title": "更改用户权限组",
"desc": "更新 <span className=\"font-medium\">{{username}}</span> 的权限",
"roleInfo": "<p>请选择此用户的适当角色:</p><ul className=\"mt-2 space-y-1 pl-5\"><li> • <span className=\"font-medium\">管理员 (Admin)</span> 拥有所有功能的完整访问权限。</li><li> • <span className=\"font-medium\">查看者 (Viewer)</span> 仅限访问实时监控、回放、探测和导出功能。</li></ul>"
}
}
},
"notification": {
"title": "通知",
"notificationSettings": {
"title": "通知设置",
"desc": "Frigate 在浏览器中运行或作为 PWA 安装时,可以原生向您的设备发送推送通知。",
"documentation": "阅读文档(英文)"
},
"globalSettings": {
"title": "全局设置",
"desc": "临时暂停所有已注册设备上特定摄像头的通知。"
},
"notificationUnavailable": {
"title": "通知功能不可用",
"desc": "网页推送通知需要安全连接(<code>https://...</code>)。这是浏览器的限制。请通过安全方式访问 Frigate 以使用通知功能。",
"documentation": "阅读文档(英文)"
},
"email": {
"title": "电子邮箱",
"placeholder": "例如example@email.com",
"desc": "需要输入有效的电子邮件,在推送服务出现问题时,将使用此电子邮件进行通知。"
},
"cameras": {
"title": "摄像头",
"noCameras": "没有可用的摄像头",
"desc": "选择要启用通知的摄像头。"
},
"deviceSpecific": "设备专用设置",
"registerDevice": "注册该设备",
"unregisterDevice": "取消注册该设备",
"sendTestNotification": "发送测试通知",
"active": "通知已启用",
"suspended": "通知已暂停 {{time}}",
"suspendTime": {
"5minutes": "暂停 5 分钟",
"10minutes": "暂停 10 分钟",
"30minutes": "暂停 30 分钟",
"1hour": "暂停 1 小时",
"12hours": "暂停 12 小时",
"24hours": "暂停 24 小时",
"untilRestart": "暂停直到重启"
},
"cancelSuspension": "取消暂停",
"toast": {
"success": {
"registered": "已成功注册通知。需要重启 Frigate 才能发送任何通知(包括测试通知)。",
"settingSaved": "通知设置已保存。"
},
"error": {
"registerFailed": "通知注册失败。"
}
}
},
"frigatePlus": {
"title": "Frigate+ 设置",
"apiKey": {
"title": "Frigate+ API 密钥",
"validated": "Frigate+ API 密钥已检测并验证通过",
"notValidated": "未检测到 Frigate+ API 密钥或验证未通过",
"desc": "Frigate+ API 密钥用于启用与 Frigate+ 服务的集成。",
"plusLink": "了解更多关于 Frigate+"
},
"snapshotConfig": {
"title": "快照配置",
"desc": "提交到 Frigate+ 需要同时在配置中启用快照和 <code>clean_copy</code> 快照。",
"documentation": "阅读文档",
"cleanCopyWarning": "部分摄像头已启用快照但未启用 <code>clean_copy</code>。您需要在快照配置中启用 <code>clean_copy</code>,才能将这些摄像头的图像提交到 Frigate+。",
"table": {
"camera": "摄像头",
"snapshots": "快照",
"cleanCopySnapshots": "<code>clean_copy</code> 快照"
}
},
"modelInfo": {
"title": "模型信息",
"modelType": "模型类型",
"trainDate": "训练日期",
"baseModel": "基础模型",
"supportedDetectors": "支持的检测器",
"cameras": "摄像头",
"loading": "正在加载模型信息...",
"error": "加载模型信息失败"
}
}
}

View File

@ -0,0 +1,156 @@
{
"documentTitle": {
"cameras": "摄像头统计 - Frigate",
"storage": "存储统计 - Frigate",
"general": "常规统计 - Frigate",
"features": "功能统计 - Frigate",
"logs": {
"frigate": "Frigate 日志 - Frigate",
"go2rtc": "Go2RTC 日志 - Frigate",
"nginx": "Nginx 日志 - Frigate"
}
},
"title": "系统",
"metrics": "系统指标",
"logs": {
"download": {
"label": "下载日志"
},
"copy": {
"label": "复制到剪贴板",
"success": "已复制日志到剪贴板",
"error": "无法复制日志到剪贴板"
},
"type": {
"label": "类型",
"timestamp": "时间戳",
"tag": "标签",
"message": "消息"
},
"tips": "日志正在从服务器流式传输",
"toast": {
"error": {
"fetchingLogsFailed": "获取日志出错:{{errorMessage}}",
"whileStreamingLogs": "流式传输日志时出错:{{errorMessage}}"
}
}
},
"general": {
"title": "常规",
"detector": {
"title": "探测器",
"inferenceSpeed": "探测器推理速度",
"cpuUsage": "探测器CPU使用率",
"memoryUsage": "探测器内存使用率"
},
"hardwareInfo": {
"title": "硬件信息",
"gpuUsage": "GPU使用率",
"gpuMemory": "GPU显存",
"gpuEncoder": "GPU编码",
"gpuDecoder": "GPU解码",
"gpuInfo": {
"vainfoOutput": {
"title": "Vainfo 输出",
"returnCode": "返回代码:{{code}}",
"processOutput": "进程输出:",
"processError": "进程错误:"
},
"nvidiaSMIOutput": {
"title": "Nvidia SMI 输出",
"name": "名称:{{name}}",
"driver": "驱动:{{driver}}",
"cudaComputerCapability": "CUDA计算能力{{cuda_compute}}",
"vbios": "VBios信息{{vbios}}"
},
"closeInfo": {
"label": "关闭GPU信息"
},
"copyInfo": {
"label": "复制GPU信息"
},
"toast": {
"success": "已复制GPU信息到剪贴板"
}
}
},
"otherProcesses": {
"title": "其他进程",
"processCpuUsage": "主进程CPU使用率",
"processMemoryUsage": "主进程内存使用率"
}
},
"storage": {
"title": "存储",
"overview": "概览",
"recordings": {
"title": "录制内容",
"tips": "该值表示 Frigate 数据库中录制内容所使用的总存储空间。Frigate 不会追踪磁盘上所有文件的存储使用情况。",
"earliestRecording": "最早的可用录制:"
},
"cameraStorage": {
"title": "摄像头存储",
"camera": "摄像头",
"unusedStorageInformation": "未使用存储信息",
"storageUsed": "存储使用",
"percentageOfTotalUsed": "总使用率",
"bandwidth": "带宽",
"unused": {
"title": "未使用",
"tips": "如果您的驱动器上存储了除 Frigate 录制内容之外的其他文件,该值可能无法准确反映 Frigate 可用的剩余空间。Frigate 不会追踪录制内容以外的存储使用情况。"
}
}
},
"cameras": {
"title": "摄像头",
"overview": "概览",
"info": {
"cameraProbeInfo": "{{camera}} 的摄像头信息",
"streamDataFromFFPROBE": "流数据信息通过<code>ffprobe</code>获取。",
"fetching": "正在获取摄像头数据",
"stream": "视频流{{idx}}",
"video": "视频:",
"codec": "编解码器:",
"resolution": "分辨率:",
"fps": "帧率:",
"unknown": "未知",
"audio": "音频:",
"error": "错误:{{error}}",
"tips": {
"title": "摄像头信息"
}
},
"framesAndDetections": "帧数/检测次数",
"label": {
"camera": "摄像头",
"detect": "探测",
"skipped": "跳过",
"ffmpeg": "ffmpeg编码器",
"capture": "捕获"
},
"toast": {
"success": {
"copyToClipboard": "已复制探测数据到剪贴板。"
},
"error": {
"unableToProbeCamera": "无法探测摄像头:{{errorMessage}}"
}
}
},
"lastRefreshed": "最后刷新时间:",
"stats": {
"ffmpegHighCpuUsage": "{{camera}} 的 FFMPEG CPU 使用率较高({{ffmpegAvg}}%",
"detectHighCpuUsage": "{{camera}} 的 探测 CPU 使用率较高({{detectAvg}}%",
"healthy": "系统运行正常",
"reindexingEmbeddings": "正在重新索引嵌入(已完成 {{processed}}%"
},
"features": {
"title": "功能",
"embeddings": {
"image_embedding_speed": "图像特征提取速度",
"face_embedding_speed": "人脸特征提取速度",
"plate_recognition_speed": "车牌识别速度",
"text_embedding_speed": "文本编码速度"
}
}
}

View File

@ -44,7 +44,8 @@ function useValue(): useValueReturn {
return; return;
} }
const cameraActivity: { [key: string]: object } = JSON.parse(activityValue); const cameraActivity: { [key: string]: FrigateCameraState } =
JSON.parse(activityValue);
if (Object.keys(cameraActivity).length === 0) { if (Object.keys(cameraActivity).length === 0) {
return; return;
@ -64,9 +65,7 @@ function useValue(): useValueReturn {
autotracking, autotracking,
alerts, alerts,
detections, detections,
} = } = state["config"];
// @ts-expect-error we know this is correct
state["config"];
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF"; cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF"; cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF"; cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
@ -174,7 +173,7 @@ export function useEnabledState(camera: string): {
value: { payload }, value: { payload },
send, send,
} = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`); } = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`);
return { payload: (payload ?? "ON") as ToggleableSetting, send }; return { payload: payload as ToggleableSetting, send };
} }
export function useDetectState(camera: string): { export function useDetectState(camera: string): {

View File

@ -5,12 +5,16 @@ import {
} from "@/context/statusbar-provider"; } from "@/context/statusbar-provider";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats"; import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import { useContext, useEffect, useMemo } from "react"; import { useContext, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FaCheck } from "react-icons/fa"; import { FaCheck } from "react-icons/fa";
import { IoIosWarning } from "react-icons/io"; import { IoIosWarning } from "react-icons/io";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export default function Statusbar() { export default function Statusbar() {
const { t } = useTranslation(["views/system"]);
const { messages, addMessage, clearMessages } = useContext( const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext, StatusBarMessagesContext,
)!; )!;
@ -50,14 +54,19 @@ export default function Statusbar() {
clearMessages("embeddings-reindex"); clearMessages("embeddings-reindex");
addMessage( addMessage(
"embeddings-reindex", "embeddings-reindex",
`Reindexing embeddings (${Math.floor((reindexState.processed_objects / reindexState.total_objects) * 100)}% complete)`, t("stats.reindexingEmbeddings", {
processed: Math.floor(
(reindexState.processed_objects / reindexState.total_objects) *
100,
),
}),
); );
} }
if (reindexState.status === "completed") { if (reindexState.status === "completed") {
clearMessages("embeddings-reindex"); clearMessages("embeddings-reindex");
} }
} }
}, [reindexState, addMessage, clearMessages]); }, [reindexState, addMessage, clearMessages, t]);
return ( return (
<div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground"> <div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground">
@ -129,7 +138,7 @@ export default function Statusbar() {
{Object.entries(messages).length === 0 ? ( {Object.entries(messages).length === 0 ? (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" /> <FaCheck className="size-3 text-green-500" />
System is healthy {t("stats.healthy")}
</div> </div>
) : ( ) : (
Object.entries(messages).map(([key, messageArray]) => ( Object.entries(messages).map(([key, messageArray]) => (

View File

@ -21,16 +21,18 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { AuthContext } from "@/context/auth-context"; import { AuthContext } from "@/context/auth-context";
import { useTranslation } from "react-i18next";
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {} interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
export function UserAuthForm({ className, ...props }: UserAuthFormProps) { export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
const { t } = useTranslation(["components/auth"]);
const [isLoading, setIsLoading] = React.useState<boolean>(false); const [isLoading, setIsLoading] = React.useState<boolean>(false);
const { login } = React.useContext(AuthContext); const { login } = React.useContext(AuthContext);
const formSchema = z.object({ const formSchema = z.object({
user: z.string().min(1, "Username is required"), user: z.string().min(1, t("form.errors.usernameRequired")),
password: z.string().min(1, "Password is required"), password: z.string().min(1, t("form.errors.passwordRequired")),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@ -62,20 +64,20 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const err = error as AxiosError; const err = error as AxiosError;
if (err.response?.status === 429) { if (err.response?.status === 429) {
toast.error("Exceeded rate limit. Try again later.", { toast.error(t("form.errors.rateLimit"), {
position: "top-center", position: "top-center",
}); });
} else if (err.response?.status === 401) { } else if (err.response?.status === 401) {
toast.error("Login failed", { toast.error(t("form.errors.loginFailed"), {
position: "top-center", position: "top-center",
}); });
} else { } else {
toast.error("Unknown error. Check logs.", { toast.error(t("form.errors.unknownError"), {
position: "top-center", position: "top-center",
}); });
} }
} else { } else {
toast.error("Unknown error. Check console logs.", { toast.error(t("form.errors.webUnknownError"), {
position: "top-center", position: "top-center",
}); });
} }
@ -92,7 +94,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
name="user" name="user"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>User</FormLabel> <FormLabel>{t("form.user")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
@ -107,7 +109,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
name="password" name="password"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>{t("form.password")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]" className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
@ -123,10 +125,10 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
variant="select" variant="select"
disabled={isLoading} disabled={isLoading}
className="flex flex-1" className="flex flex-1"
aria-label="Login" aria-label={t("form.login")}
> >
{isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />} {isLoading && <ActivityIndicator className="mr-2 h-4 w-4" />}
Login {t("form.login")}
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -3,6 +3,7 @@ import { toast } from "sonner";
import { FaDownload } from "react-icons/fa"; import { FaDownload } from "react-icons/fa";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type DownloadVideoButtonProps = { type DownloadVideoButtonProps = {
source: string; source: string;
@ -17,6 +18,7 @@ export function DownloadVideoButton({
startTime, startTime,
className, className,
}: DownloadVideoButtonProps) { }: DownloadVideoButtonProps) {
const { t } = useTranslation(["components/input"]);
const formattedDate = formatUnixTimestampToDateTime(startTime, { const formattedDate = formatUnixTimestampToDateTime(startTime, {
strftime_fmt: "%D-%T", strftime_fmt: "%D-%T",
time_style: "medium", time_style: "medium",
@ -25,7 +27,7 @@ export function DownloadVideoButton({
const filename = `${camera}_${formattedDate}.mp4`; const filename = `${camera}_${formattedDate}.mp4`;
const handleDownloadStart = () => { const handleDownloadStart = () => {
toast.success("Your review item video has started downloading.", { toast.success(t("button.downloadVideo.toast.success"), {
position: "top-center", position: "top-center",
}); });
}; };
@ -36,7 +38,7 @@ export function DownloadVideoButton({
asChild asChild
className="flex items-center gap-2" className="flex items-center gap-2"
size="sm" size="sm"
aria-label="Download Video" aria-label={t("button.downloadVideo.label")}
> >
<a href={source} download={filename} onClick={handleDownloadStart}> <a href={source} download={filename} onClick={handleDownloadStart}>
<FaDownload <FaDownload

View File

@ -78,7 +78,10 @@ export default function AutoUpdatingCameraImage({
let baseParam = ""; let baseParam = "";
if (periodicCache && !isCached) { if (periodicCache && !isCached) {
baseParam = "store=1"; const date = new Date(key);
date.setMinutes(date.getMinutes() - (date.getMinutes() % 10), 0, 0);
baseParam = `store=1&cache=${date.getTime() / 1000}`;
} else { } else {
baseParam = `cache=${key}`; baseParam = `cache=${key}`;
} }

View File

@ -7,6 +7,7 @@ import { useCallback, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage"; import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage";
import { useTranslation } from "react-i18next";
type Options = { [key: string]: boolean }; type Options = { [key: string]: boolean };
@ -21,6 +22,7 @@ export default function DebugCameraImage({
className, className,
cameraConfig, cameraConfig,
}: DebugCameraImageProps) { }: DebugCameraImageProps) {
const { t } = useTranslation(["components/camera"]);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [options, setOptions] = usePersistence<Options>( const [options, setOptions] = usePersistence<Options>(
`${cameraConfig?.name}-feed`, `${cameraConfig?.name}-feed`,
@ -59,17 +61,21 @@ export default function DebugCameraImage({
onClick={handleToggleSettings} onClick={handleToggleSettings}
variant="link" variant="link"
size="sm" size="sm"
aria-label="Settings" aria-label={t("debug.options.label")}
> >
<span className="h-5 w-5"> <span className="h-5 w-5">
<LuSettings /> <LuSettings />
</span>{" "} </span>{" "}
<span>{showSettings ? "Hide" : "Show"} Options</span> <span>
{showSettings
? t("debug.options.hideOptions")
: t("debug.options.showOptions")}
</span>
</Button> </Button>
{showSettings ? ( {showSettings ? (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Options</CardTitle> <CardTitle>{t("debug.options.title")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DebugSettings <DebugSettings
@ -89,6 +95,7 @@ type DebugSettingsProps = {
}; };
function DebugSettings({ handleSetOption, options }: DebugSettingsProps) { function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
const { t } = useTranslation(["components/camera"]);
return ( return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -99,7 +106,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("bbox", isChecked); handleSetOption("bbox", isChecked);
}} }}
/> />
<Label htmlFor="bbox">Bounding Box</Label> <Label htmlFor="bbox">{t("debug.boundingBox")}</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
@ -109,7 +116,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("timestamp", isChecked); handleSetOption("timestamp", isChecked);
}} }}
/> />
<Label htmlFor="timestamp">Timestamp</Label> <Label htmlFor="timestamp">{t("debug.timestamp")}</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
@ -119,7 +126,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("zones", isChecked); handleSetOption("zones", isChecked);
}} }}
/> />
<Label htmlFor="zones">Zones</Label> <Label htmlFor="zones">{t("debug.zones")}</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
@ -129,7 +136,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("mask", isChecked); handleSetOption("mask", isChecked);
}} }}
/> />
<Label htmlFor="mask">Mask</Label> <Label htmlFor="mask">{t("debug.mask")}</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
@ -139,7 +146,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("motion", isChecked); handleSetOption("motion", isChecked);
}} }}
/> />
<Label htmlFor="motion">Motion</Label> <Label htmlFor="motion">{t("debug.motion")}</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
@ -149,7 +156,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) {
handleSetOption("regions", isChecked); handleSetOption("regions", isChecked);
}} }}
/> />
<Label htmlFor="regions">Regions</Label> <Label htmlFor="regions">{t("debug.regions")}</Label>
</div> </div>
</div> </div>
); );

View File

@ -18,6 +18,7 @@ import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type AnimatedEventCardProps = { type AnimatedEventCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -29,6 +30,7 @@ export function AnimatedEventCard({
selectedGroup, selectedGroup,
updateEvents, updateEvents,
}: AnimatedEventCardProps) { }: AnimatedEventCardProps) {
const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const apiHost = useApiHost(); const apiHost = useApiHost();
@ -121,7 +123,7 @@ export function AnimatedEventCard({
<Button <Button
className="absolute right-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500" className="absolute right-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
size="xs" size="xs"
aria-label="Mark as Reviewed" aria-label={t("markAsReviewed")}
onClick={async () => { onClick={async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] }); await axios.post(`reviews/viewed`, { ids: [event.id] });
updateEvents(); updateEvents();
@ -130,7 +132,7 @@ export function AnimatedEventCard({
<FaCircleCheck className="size-3 text-white" /> <FaCircleCheck className="size-3 text-white" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Mark as Reviewed</TooltipContent> <TooltipContent>{t("markAsReviewed")}</TooltipContent>
</Tooltip> </Tooltip>
)} )}
{previews != undefined && ( {previews != undefined && (

View File

@ -20,6 +20,7 @@ import { MdEditSquare } from "react-icons/md";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil"; import { shareOrCopy } from "@/utils/browserUtil";
import { useTranslation } from "react-i18next";
type ExportProps = { type ExportProps = {
className: string; className: string;
@ -36,6 +37,7 @@ export default function ExportCard({
onRename, onRename,
onDelete, onDelete,
}: ExportProps) { }: ExportProps) {
const { t } = useTranslation(["views/exports"]);
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [loading, setLoading] = useState( const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0, exportedRecording.thumb_path.length > 0,
@ -89,10 +91,8 @@ export default function ExportCard({
} }
}} }}
> >
<DialogTitle>Rename Export</DialogTitle> <DialogTitle>{t("editExport.title")}</DialogTitle>
<DialogDescription> <DialogDescription>{t("editExport.desc")}</DialogDescription>
Enter a new name for this export.
</DialogDescription>
{editName && ( {editName && (
<> <>
<Input <Input
@ -113,13 +113,13 @@ export default function ExportCard({
/> />
<DialogFooter> <DialogFooter>
<Button <Button
aria-label="Save Export" aria-label={t("editExport.saveExport")}
size="sm" size="sm"
variant="select" variant="select"
disabled={(editName?.update?.length ?? 0) == 0} disabled={(editName?.update?.length ?? 0) == 0}
onClick={() => submitRename()} onClick={() => submitRename()}
> >
Save {t("button.save", { ns: "common" })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
@ -207,7 +207,7 @@ export default function ExportCard({
{!exportedRecording.in_progress && ( {!exportedRecording.in_progress && (
<Button <Button
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white" className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
aria-label="Play" aria-label={t("button.play", { ns: "common" })}
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
onSelect(exportedRecording); onSelect(exportedRecording);

View File

@ -35,6 +35,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { buttonVariants } from "../ui/button"; import { buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -46,6 +47,7 @@ export default function ReviewCard({
currentTime, currentTime,
onClick, onClick,
}: ReviewCardProps) { }: ReviewCardProps) {
const { t } = useTranslation(["components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
@ -82,26 +84,20 @@ export default function ReviewCard({
) )
.then((response) => { .then((response) => {
if (response.status == 200) { if (response.status == 200) {
toast.success( toast.success(t("export.toast.success"), {
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
);
}
})
.catch((error) => {
if (error.response?.data?.message) {
toast.error(
`Failed to start export: ${error.response.data.message}`,
{ position: "top-center" },
);
} else {
toast.error(`Failed to start export: ${error.message}`, {
position: "top-center", position: "top-center",
}); });
} }
})
.catch((error) => {
const errorMessage =
error.response?.data?.message || error.message || "Unknown error";
toast.error(t("export.toast.error.failed", { error: errorMessage }), {
position: "top-center",
});
}); });
setOptionsOpen(false); setOptionsOpen(false);
}, [event]); }, [event, t]);
const onDelete = useCallback(async () => { const onDelete = useCallback(async () => {
await axios.post(`reviews/delete`, { ids: [event.id] }); await axios.post(`reviews/delete`, { ids: [event.id] });
@ -216,24 +212,24 @@ export default function ReviewCard({
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle> <AlertDialogTitle>
{t("recording.confirmDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete all recorded video associated with <Trans ns="components/dialog">
this review item? recording.confirmDelete.title
<br /> </Trans>
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}> <AlertDialogCancel onClick={() => setOptionsOpen(false)}>
Cancel {t("button.cancel", { ns: "common" })}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className={buttonVariants({ variant: "destructive" })} className={buttonVariants({ variant: "destructive" })}
onClick={onDelete} onClick={onDelete}
> >
Delete {t("button.delete", { ns: "common" })}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -247,7 +243,9 @@ export default function ReviewCard({
onClick={onExport} onClick={onExport}
> >
<FaCompactDisc className="text-secondary-foreground" /> <FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div> <div className="text-primary">
{t("recording.button.export")}
</div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
{!event.has_been_reviewed && ( {!event.has_been_reviewed && (
@ -257,7 +255,9 @@ export default function ReviewCard({
onClick={onMarkAsReviewed} onClick={onMarkAsReviewed}
> >
<FaCircleCheck className="text-secondary-foreground" /> <FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div> <div className="text-primary">
{t("recording.button.markAsReviewed")}
</div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
)} )}
@ -268,7 +268,9 @@ export default function ReviewCard({
> >
<HiTrash className="text-secondary-foreground" /> <HiTrash className="text-secondary-foreground" />
<div className="text-primary"> <div className="text-primary">
{bypassDialogRef.current ? "Delete Now" : "Delete"} {bypassDialogRef.current
? t("recording.button.deleteNow")
: t("button.delete", { ns: "common" })}
</div> </div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
@ -286,24 +288,24 @@ export default function ReviewCard({
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle> <AlertDialogTitle>
{t("recording.confirmDelete.title")}
</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete all recorded video associated with <Trans ns="components/dialog">
this review item? recording.confirmDelete.desc.selected
<br /> </Trans>
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}> <AlertDialogCancel onClick={() => setOptionsOpen(false)}>
Cancel {t("button.cancel", { ns: "common" })}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className={buttonVariants({ variant: "destructive" })} className={buttonVariants({ variant: "destructive" })}
onClick={onDelete} onClick={onDelete}
> >
Delete {t("button.delete", { ns: "common" })}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -316,7 +318,7 @@ export default function ReviewCard({
onClick={onExport} onClick={onExport}
> >
<FaCompactDisc className="text-secondary-foreground" /> <FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div> <div className="text-primary">{t("recording.button.export")}</div>
</div> </div>
{!event.has_been_reviewed && ( {!event.has_been_reviewed && (
<div <div
@ -324,7 +326,9 @@ export default function ReviewCard({
onClick={onMarkAsReviewed} onClick={onMarkAsReviewed}
> >
<FaCircleCheck className="text-secondary-foreground" /> <FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div> <div className="text-primary">
{t("recording.button.markAsReviewed")}
</div>
</div> </div>
)} )}
<div <div
@ -333,7 +337,9 @@ export default function ReviewCard({
> >
<HiTrash className="text-secondary-foreground" /> <HiTrash className="text-secondary-foreground" />
<div className="text-primary"> <div className="text-primary">
{bypassDialogRef.current ? "Delete Now" : "Delete"} {bypassDialogRef.current
? t("recording.button.deleteNow")
: t("button.delete", { ns: "common" })}
</div> </div>
</div> </div>
</DrawerContent> </DrawerContent>

View File

@ -8,11 +8,11 @@ import Chip from "@/components/indicators/Chip";
import useImageLoaded from "@/hooks/use-image-loaded"; import useImageLoaded from "@/hooks/use-image-loaded";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { SearchResult } from "@/types/search"; import { SearchResult } from "@/types/search";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import useContextMenu from "@/hooks/use-contextmenu"; import useContextMenu from "@/hooks/use-contextmenu";
import { useTranslation } from "react-i18next";
type SearchThumbnailProps = { type SearchThumbnailProps = {
searchResult: SearchResult; searchResult: SearchResult;
@ -23,6 +23,7 @@ export default function SearchThumbnail({
searchResult, searchResult,
onClick, onClick,
}: SearchThumbnailProps) { }: SearchThumbnailProps) {
const { t } = useTranslation(["views/search"]);
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
@ -113,7 +114,7 @@ export default function SearchThumbnail({
.filter( .filter(
(item) => item !== undefined && !item.includes("-verified"), (item) => item !== undefined && !item.includes("-verified"),
) )
.map((text) => capitalizeFirstLetter(text)) .map((text) => t(text, { ns: "objects" }))
.sort() .sort()
.join(", ") .join(", ")
.replaceAll("-verified", "")} .replaceAll("-verified", "")}

View File

@ -6,6 +6,7 @@ import { SearchResult } from "@/types/search";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import SearchResultActions from "../menu/SearchResultActions"; import SearchResultActions from "../menu/SearchResultActions";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type SearchThumbnailProps = { type SearchThumbnailProps = {
searchResult: SearchResult; searchResult: SearchResult;
@ -24,12 +25,15 @@ export default function SearchThumbnailFooter({
showObjectLifecycle, showObjectLifecycle,
showSnapshot, showSnapshot,
}: SearchThumbnailProps) { }: SearchThumbnailProps) {
const { t } = useTranslation(["views/search"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
// date // date
const formattedDate = useFormattedTimestamp( const formattedDate = useFormattedTimestamp(
searchResult.start_time, searchResult.start_time,
config?.ui.time_format == "24hour" ? "%b %-d, %H:%M" : "%b %-d, %I:%M %p", config?.ui.time_format == "24hour"
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
: t("time.formattedTimestampExcludeSeconds.12hour", { ns: "common" }),
config?.ui.timezone, config?.ui.timezone,
); );

Some files were not shown because too many files have changed in this diff Show More