From 447f26e1b97db60dec0a225744428dbe77a1a658 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:29:34 -0600 Subject: [PATCH 01/82] Fix lpr metrics and add yolov9 plate detection metric (#16827) --- .../common/license_plate/mixin.py | 22 +++++++++++++++++++ frigate/data_processing/post/license_plate.py | 15 ++++--------- .../real_time/license_plate.py | 9 -------- frigate/data_processing/types.py | 2 ++ frigate/stats/util.py | 4 ++++ 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index 1723d213e..aa03bc985 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -816,6 +816,20 @@ class LicensePlateProcessingMixin: # 5. Return True if we should keep the previous plate (i.e., if it scores higher) return prev_score > curr_score + def __update_yolov9_metrics(self, duration: float) -> None: + """ + Update inference metrics. + """ + self.metrics.yolov9_lpr_fps.value = ( + self.metrics.yolov9_lpr_fps.value * 9 + duration + ) / 10 + + def __update_lpr_metrics(self, duration: float) -> None: + """ + Update inference metrics. + """ + self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10 + def lpr_process(self, obj_data: dict[str, any], frame: np.ndarray): """Look for license plates in image.""" @@ -843,6 +857,7 @@ class LicensePlateProcessingMixin: if self.requires_license_plate_detection: logger.debug("Running manual license_plate detection.") + car_box = obj_data.get("box") if not car_box: @@ -867,6 +882,9 @@ class LicensePlateProcessingMixin: logger.debug( f"YOLOv9 LPD inference time: {(datetime.datetime.now().timestamp() - yolov9_start) * 1000:.2f} ms" ) + self.__update_yolov9_metrics( + datetime.datetime.now().timestamp() - yolov9_start + ) if not license_plate: logger.debug("Detected no license plates for car object.") @@ -945,11 +963,15 @@ class LicensePlateProcessingMixin: license_plate_frame, ) + start = datetime.datetime.now().timestamp() + # run detection, returns results sorted by confidence, best first license_plates, confidences, areas = self._process_license_plate( license_plate_frame ) + self.__update_lpr_metrics(datetime.datetime.now().timestamp() - start) + logger.debug(f"Text boxes: {license_plates}") logger.debug(f"Confidences: {confidences}") logger.debug(f"Areas: {areas}") diff --git a/frigate/data_processing/post/license_plate.py b/frigate/data_processing/post/license_plate.py index 9a9974bc7..2c80418c7 100644 --- a/frigate/data_processing/post/license_plate.py +++ b/frigate/data_processing/post/license_plate.py @@ -40,12 +40,6 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): self.config = config super().__init__(config, metrics, model_runner) - def __update_metrics(self, duration: float) -> None: - """ - Update inference metrics. - """ - self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10 - def process_data( self, data: dict[str, any], data_type: PostProcessDataEnum ) -> None: @@ -57,8 +51,6 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): Returns: None. """ - start = datetime.datetime.now().timestamp() - event_id = data["event_id"] camera_name = data["camera"] @@ -128,7 +120,10 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): return if WRITE_DEBUG_IMAGES: - cv2.imwrite(f"debug/frames/lpr_post_{start}.jpg", image) + cv2.imwrite( + f"debug/frames/lpr_post_{datetime.datetime.now().timestamp()}.jpg", + image, + ) # convert to yuv for processing frame = cv2.cvtColor(image, cv2.COLOR_BGR2YUV_I420) @@ -210,8 +205,6 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): logger.debug(f"Post processing plate: {event_id}, {frame_time}") self.lpr_process(keyframe_obj_data, frame) - self.__update_metrics(datetime.datetime.now().timestamp() - start) - def handle_request(self, topic, request_data) -> dict[str, any] | None: if topic == EmbeddingsRequestEnum.reprocess_plate.value: event = request_data["event"] diff --git a/frigate/data_processing/real_time/license_plate.py b/frigate/data_processing/real_time/license_plate.py index 2809e861f..c8f0efa11 100644 --- a/frigate/data_processing/real_time/license_plate.py +++ b/frigate/data_processing/real_time/license_plate.py @@ -1,6 +1,5 @@ """Handle processing images for face detection and recognition.""" -import datetime import logging import numpy as np @@ -33,17 +32,9 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess self.config = config super().__init__(config, metrics) - def __update_metrics(self, duration: float) -> None: - """ - Update inference metrics. - """ - self.metrics.alpr_pps.value = (self.metrics.alpr_pps.value * 9 + duration) / 10 - def process_frame(self, obj_data: dict[str, any], frame: np.ndarray): """Look for license plates in image.""" - start = datetime.datetime.now().timestamp() self.lpr_process(obj_data, frame) - self.__update_metrics(datetime.datetime.now().timestamp() - start) def handle_request(self, topic, request_data) -> dict[str, any] | None: return diff --git a/frigate/data_processing/types.py b/frigate/data_processing/types.py index 6f87f77f9..29abb22d1 100644 --- a/frigate/data_processing/types.py +++ b/frigate/data_processing/types.py @@ -10,12 +10,14 @@ class DataProcessorMetrics: text_embeddings_sps: Synchronized face_rec_fps: Synchronized alpr_pps: Synchronized + yolov9_lpr_fps: Synchronized def __init__(self): self.image_embeddings_fps = mp.Value("d", 0.01) self.text_embeddings_sps = mp.Value("d", 0.01) self.face_rec_fps = mp.Value("d", 0.01) self.alpr_pps = mp.Value("d", 0.01) + self.yolov9_lpr_fps = mp.Value("d", 0.01) class DataProcessorModelRunner: diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 262cec3d2..3d836868e 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -302,6 +302,10 @@ def stats_snapshot( stats["embeddings"]["plate_recognition_speed"] = round( embeddings_metrics.alpr_pps.value * 1000, 2 ) + if "license_plate" not in config.objects.all_objects: + stats["embeddings"]["yolov9_plate_detection_speed"] = round( + embeddings_metrics.yolov9_lpr_fps.value * 1000, 2 + ) get_processing_stats(config, stats, hwaccel_errors) From d0e9bcbfdcffdcbe0c7eb45971bd16ef41ddce8b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 26 Feb 2025 08:58:25 -0600 Subject: [PATCH 02/82] Add ability to use Jina CLIP V2 for semantic search (#16826) * add wheels * move extra index url to bottom * config model option * add postprocess * fix config * jina v2 embedding class * use jina v2 in embeddings * fix ov inference * frontend * update reference config * revert device * fix truncation * return np tensors * use correct embeddings from inference * manual preprocess * clean up * docs * lower batch size for v2 only * docs clarity * wording --- docker/main/requirements-wheels.txt | 1 - docs/docs/configuration/reference.md | 2 + docs/docs/configuration/semantic_search.md | 34 ++- frigate/config/classification.py | 12 +- frigate/embeddings/embeddings.py | 86 +++++-- frigate/embeddings/onnx/base_embedding.py | 7 +- frigate/embeddings/onnx/jina_v2_embedding.py | 231 +++++++++++++++++++ frigate/embeddings/onnx/runner.py | 9 +- web/src/pages/Explore.tsx | 45 +++- web/src/types/frigateConfig.ts | 2 + 10 files changed, 380 insertions(+), 49 deletions(-) create mode 100644 frigate/embeddings/onnx/jina_v2_embedding.py diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 320ce3334..25286617e 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -54,7 +54,6 @@ pywebpush == 2.0.* pyclipper == 1.3.* shapely == 2.0.* Levenshtein==0.26.* -prometheus-client == 0.21.* # HailoRT Wheels appdirs==1.4.* argcomplete==2.0.* diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index b791e708a..c64272214 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -536,6 +536,8 @@ semantic_search: enabled: False # Optional: Re-index embeddings database from historical tracked objects (default: shown below) reindex: False + # Optional: Set the model used for embeddings. (default: shown below) + model: "jinav1" # Optional: Set the model size used for embeddings. (default: shown below) # NOTE: small model runs on CPU and large model runs on GPU model_size: "small" diff --git a/docs/docs/configuration/semantic_search.md b/docs/docs/configuration/semantic_search.md index bd3d79cae..07e2cbfb2 100644 --- a/docs/docs/configuration/semantic_search.md +++ b/docs/docs/configuration/semantic_search.md @@ -5,7 +5,7 @@ title: Semantic Search 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. This feature works by creating _embeddings_ — numerical vector representations — for both the images and text descriptions of your tracked objects. By comparing these embeddings, Frigate assesses their similarities to deliver relevant search results. -Frigate uses [Jina AI's CLIP model](https://huggingface.co/jinaai/jina-clip-v1) to create and save embeddings to Frigate's database. All of this runs locally. +Frigate uses models from [Jina AI](https://huggingface.co/jinaai) to create and save embeddings to Frigate's database. All of this runs locally. Semantic Search is accessed via the _Explore_ view in the Frigate UI. @@ -35,23 +35,47 @@ If you are enabling Semantic Search for the first time, be advised that Frigate ::: -### Jina AI CLIP +### Jina AI CLIP (version 1) -The vision model is able to embed both images and text into the same vector space, which allows `image -> image` and `text -> image` similarity searches. Frigate uses this model on tracked objects to encode the thumbnail image and store it in the database. When searching for tracked objects via text in the search box, Frigate will perform a `text -> image` similarity search against this embedding. When clicking "Find Similar" in the tracked object detail pane, Frigate will perform an `image -> image` similarity search to retrieve the closest matching thumbnails. +The [V1 model from Jina](https://huggingface.co/jinaai/jina-clip-v1) has a vision model which is able to embed both images and text into the same vector space, which allows `image -> image` and `text -> image` similarity searches. Frigate uses this model on tracked objects to encode the thumbnail image and store it in the database. When searching for tracked objects via text in the search box, Frigate will perform a `text -> image` similarity search against this embedding. When clicking "Find Similar" in the tracked object detail pane, Frigate will perform an `image -> image` similarity search to retrieve the closest matching thumbnails. -The text model is used to embed tracked object descriptions and perform searches against them. Descriptions can be created, viewed, and modified on the Explore page when clicking on thumbnail of a tracked object. See [the Generative AI docs](/configuration/genai.md) for more information on how to automatically generate tracked object descriptions. +The V1 text model is used to embed tracked object descriptions and perform searches against them. Descriptions can be created, viewed, and modified on the Explore page when clicking on thumbnail of a tracked object. See [the Generative AI docs](/configuration/genai.md) for more information on how to automatically generate tracked object descriptions. -Differently weighted versions of the Jina model are available and can be selected by setting the `model_size` config option as `small` or `large`: +Differently weighted versions of the Jina models are available and can be selected by setting the `model_size` config option as `small` or `large`: ```yaml semantic_search: enabled: True + model: "jinav1" model_size: small ``` - Configuring the `large` model employs the full Jina model and will automatically run on the GPU if applicable. - Configuring the `small` model employs a quantized version of the Jina model that uses less RAM and runs on CPU with a very negligible difference in embedding quality. +### Jina AI CLIP (version 2) + +Frigate also supports the [V2 model from Jina](https://huggingface.co/jinaai/jina-clip-v2), which introduces multilingual support (89 languages). In contrast, the V1 model only supports English. + +V2 offers only a 3% performance improvement over V1 in both text-image and text-text retrieval tasks, an upgrade that is unlikely to yield noticeable real-world benefits. Additionally, V2 has _significantly_ higher RAM and GPU requirements, leading to increased inference time and memory usage. If you plan to use V2, ensure your system has ample RAM and a discrete GPU. CPU inference (with the `small` model) using V2 is not recommended. + +To use the V2 model, update the `model` parameter in your config: + +```yaml +semantic_search: + enabled: True + model: "jinav2" + model_size: large +``` + +For most users, especially native English speakers, the V1 model remains the recommended choice. + +:::note + +Switching between V1 and V2 requires reindexing your embeddings. To do this, set `reindex: True` in your Semantic Search configuration and restart Frigate. The embeddings from V1 and V2 are incompatible, and failing to reindex will result in incorrect search results. + +::: + ### GPU Acceleration The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU hardware, when available. This depends on the Docker build that is used. diff --git a/frigate/config/classification.py b/frigate/config/classification.py index 8a8e95861..f3416b009 100644 --- a/frigate/config/classification.py +++ b/frigate/config/classification.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import Dict, List, Optional from pydantic import Field @@ -11,6 +12,11 @@ __all__ = [ ] +class SemanticSearchModelEnum(str, Enum): + jinav1 = "jinav1" + jinav2 = "jinav2" + + class BirdClassificationConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable bird classification.") threshold: float = Field( @@ -30,7 +36,11 @@ class ClassificationConfig(FrigateBaseModel): class SemanticSearchConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable semantic search.") reindex: Optional[bool] = Field( - default=False, title="Reindex all detections on startup." + default=False, title="Reindex all tracked objects on startup." + ) + model: Optional[SemanticSearchModelEnum] = Field( + default=SemanticSearchModelEnum.jinav1, + title="The CLIP model to use for semantic search.", ) model_size: str = Field( default="small", title="The size of the embeddings model used." diff --git a/frigate/embeddings/embeddings.py b/frigate/embeddings/embeddings.py index c06f46ba4..7e866d1fe 100644 --- a/frigate/embeddings/embeddings.py +++ b/frigate/embeddings/embeddings.py @@ -10,6 +10,7 @@ from playhouse.shortcuts import model_to_dict from frigate.comms.inter_process import InterProcessRequestor from frigate.config import FrigateConfig +from frigate.config.classification import SemanticSearchModelEnum from frigate.const import ( CONFIG_DIR, UPDATE_EMBEDDINGS_REINDEX_PROGRESS, @@ -23,6 +24,7 @@ from frigate.util.builtin import serialize from frigate.util.path import get_event_thumbnail_bytes from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding +from .onnx.jina_v2_embedding import JinaV2Embedding logger = logging.getLogger(__name__) @@ -75,18 +77,7 @@ class Embeddings: # Create tables if they don't exist self.db.create_embeddings_tables() - models = [ - "jinaai/jina-clip-v1-text_model_fp16.onnx", - "jinaai/jina-clip-v1-tokenizer", - "jinaai/jina-clip-v1-vision_model_fp16.onnx" - if config.semantic_search.model_size == "large" - else "jinaai/jina-clip-v1-vision_model_quantized.onnx", - "jinaai/jina-clip-v1-preprocessor_config.json", - "facenet-facenet.onnx", - "paddleocr-onnx-detection.onnx", - "paddleocr-onnx-classification.onnx", - "paddleocr-onnx-recognition.onnx", - ] + models = self.get_model_definitions() for model in models: self.requestor.send_data( @@ -97,17 +88,64 @@ class Embeddings: }, ) - self.text_embedding = JinaV1TextEmbedding( - model_size=config.semantic_search.model_size, - requestor=self.requestor, - device="CPU", + if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2: + # Single JinaV2Embedding instance for both text and vision + self.embedding = JinaV2Embedding( + model_size=self.config.semantic_search.model_size, + requestor=self.requestor, + device="GPU" + if self.config.semantic_search.model_size == "large" + else "CPU", + ) + self.text_embedding = lambda input_data: self.embedding( + input_data, embedding_type="text" + ) + self.vision_embedding = lambda input_data: self.embedding( + input_data, embedding_type="vision" + ) + else: # Default to jinav1 + self.text_embedding = JinaV1TextEmbedding( + model_size=config.semantic_search.model_size, + requestor=self.requestor, + device="CPU", + ) + self.vision_embedding = JinaV1ImageEmbedding( + model_size=config.semantic_search.model_size, + requestor=self.requestor, + device="GPU" if config.semantic_search.model_size == "large" else "CPU", + ) + + def get_model_definitions(self): + # Version-specific models + if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2: + models = [ + "jinaai/jina-clip-v2-tokenizer", + "jinaai/jina-clip-v2-model_fp16.onnx" + if self.config.semantic_search.model_size == "large" + else "jinaai/jina-clip-v2-model_quantized.onnx", + "jinaai/jina-clip-v2-preprocessor_config.json", + ] + else: # Default to jinav1 + models = [ + "jinaai/jina-clip-v1-text_model_fp16.onnx", + "jinaai/jina-clip-v1-tokenizer", + "jinaai/jina-clip-v1-vision_model_fp16.onnx" + if self.config.semantic_search.model_size == "large" + else "jinaai/jina-clip-v1-vision_model_quantized.onnx", + "jinaai/jina-clip-v1-preprocessor_config.json", + ] + + # Add common models + models.extend( + [ + "facenet-facenet.onnx", + "paddleocr-onnx-detection.onnx", + "paddleocr-onnx-classification.onnx", + "paddleocr-onnx-recognition.onnx", + ] ) - self.vision_embedding = JinaV1ImageEmbedding( - model_size=config.semantic_search.model_size, - requestor=self.requestor, - device="GPU" if config.semantic_search.model_size == "large" else "CPU", - ) + return models def embed_thumbnail( self, event_id: str, thumbnail: bytes, upsert: bool = True @@ -244,7 +282,11 @@ class Embeddings: # Get total count of events to process total_events = Event.select().count() - batch_size = 32 + batch_size = ( + 4 + if self.config.semantic_search.model == SemanticSearchModelEnum.jinav2 + else 32 + ) current_page = 1 totals = { diff --git a/frigate/embeddings/onnx/base_embedding.py b/frigate/embeddings/onnx/base_embedding.py index 6f74afa2a..a2ea92674 100644 --- a/frigate/embeddings/onnx/base_embedding.py +++ b/frigate/embeddings/onnx/base_embedding.py @@ -72,6 +72,9 @@ class BaseEmbedding(ABC): return image + def _postprocess_outputs(self, outputs: any) -> any: + return outputs + def __call__( self, inputs: list[str] | list[Image.Image] | list[str] ) -> list[np.ndarray]: @@ -91,5 +94,7 @@ class BaseEmbedding(ABC): else: logger.warning(f"Expected input '{key}' not found in onnx_inputs") - embeddings = self.runner.run(onnx_inputs)[0] + outputs = self.runner.run(onnx_inputs)[0] + embeddings = self._postprocess_outputs(outputs) + return [embedding for embedding in embeddings] diff --git a/frigate/embeddings/onnx/jina_v2_embedding.py b/frigate/embeddings/onnx/jina_v2_embedding.py new file mode 100644 index 000000000..be6573e50 --- /dev/null +++ b/frigate/embeddings/onnx/jina_v2_embedding.py @@ -0,0 +1,231 @@ +"""JinaV2 Embeddings.""" + +import io +import logging +import os + +import numpy as np +from PIL import Image +from transformers import AutoTokenizer +from transformers.utils.logging import disable_progress_bar, set_verbosity_error + +from frigate.comms.inter_process import InterProcessRequestor +from frigate.const import MODEL_CACHE_DIR, UPDATE_MODEL_STATE +from frigate.types import ModelStatusTypesEnum +from frigate.util.downloader import ModelDownloader + +from .base_embedding import BaseEmbedding +from .runner import ONNXModelRunner + +# disables the progress bar and download logging for downloading tokenizers and image processors +disable_progress_bar() +set_verbosity_error() +logger = logging.getLogger(__name__) + + +class JinaV2Embedding(BaseEmbedding): + def __init__( + self, + model_size: str, + requestor: InterProcessRequestor, + device: str = "AUTO", + embedding_type: str = None, + ): + model_file = ( + "model_fp16.onnx" if model_size == "large" else "model_quantized.onnx" + ) + super().__init__( + model_name="jinaai/jina-clip-v2", + model_file=model_file, + download_urls={ + model_file: f"https://huggingface.co/jinaai/jina-clip-v2/resolve/main/onnx/{model_file}", + "preprocessor_config.json": "https://huggingface.co/jinaai/jina-clip-v2/resolve/main/preprocessor_config.json", + }, + ) + self.tokenizer_file = "tokenizer" + self.embedding_type = embedding_type + self.requestor = requestor + self.model_size = model_size + self.device = device + self.download_path = os.path.join(MODEL_CACHE_DIR, self.model_name) + self.tokenizer = None + self.image_processor = None + self.runner = None + files_names = list(self.download_urls.keys()) + [self.tokenizer_file] + if not all( + os.path.exists(os.path.join(self.download_path, n)) for n in files_names + ): + logger.debug(f"starting model download for {self.model_name}") + self.downloader = ModelDownloader( + model_name=self.model_name, + download_path=self.download_path, + file_names=files_names, + download_func=self._download_model, + ) + self.downloader.ensure_model_files() + else: + self.downloader = None + ModelDownloader.mark_files_state( + self.requestor, + self.model_name, + files_names, + ModelStatusTypesEnum.downloaded, + ) + self._load_model_and_utils() + logger.debug(f"models are already downloaded for {self.model_name}") + + def _download_model(self, path: str): + try: + file_name = os.path.basename(path) + + if file_name in self.download_urls: + ModelDownloader.download_from_url(self.download_urls[file_name], path) + elif file_name == self.tokenizer_file: + if not os.path.exists(os.path.join(path, self.model_name)): + logger.info(f"Downloading {self.model_name} tokenizer") + + tokenizer = AutoTokenizer.from_pretrained( + self.model_name, + trust_remote_code=True, + cache_dir=os.path.join( + MODEL_CACHE_DIR, self.model_name, "tokenizer" + ), + clean_up_tokenization_spaces=True, + ) + tokenizer.save_pretrained(path) + self.requestor.send_data( + UPDATE_MODEL_STATE, + { + "model": f"{self.model_name}-{file_name}", + "state": ModelStatusTypesEnum.downloaded, + }, + ) + except Exception: + self.requestor.send_data( + UPDATE_MODEL_STATE, + { + "model": f"{self.model_name}-{file_name}", + "state": ModelStatusTypesEnum.error, + }, + ) + + def _load_model_and_utils(self): + if self.runner is None: + if self.downloader: + self.downloader.wait_for_download() + + tokenizer_path = os.path.join( + f"{MODEL_CACHE_DIR}/{self.model_name}/tokenizer" + ) + self.tokenizer = AutoTokenizer.from_pretrained( + self.model_name, + cache_dir=tokenizer_path, + trust_remote_code=True, + clean_up_tokenization_spaces=True, + ) + + self.runner = ONNXModelRunner( + os.path.join(self.download_path, self.model_file), + self.device, + self.model_size, + ) + + def _preprocess_image(self, image_data: bytes | Image.Image) -> np.ndarray: + """ + Manually preprocess a single image from bytes or PIL.Image to (3, 512, 512). + """ + if isinstance(image_data, bytes): + image = Image.open(io.BytesIO(image_data)) + else: + image = image_data + + if image.mode != "RGB": + image = image.convert("RGB") + + image = image.resize((512, 512), Image.Resampling.LANCZOS) + + # Convert to numpy array, normalize to [0, 1], and transpose to (channels, height, width) + image_array = np.array(image, dtype=np.float32) / 255.0 + image_array = np.transpose(image_array, (2, 0, 1)) # (H, W, C) -> (C, H, W) + + return image_array + + def _preprocess_inputs(self, raw_inputs): + """ + Preprocess inputs into a list of real input tensors (no dummies). + - For text: Returns list of input_ids. + - For vision: Returns list of pixel_values. + """ + if not isinstance(raw_inputs, list): + raw_inputs = [raw_inputs] + + processed = [] + if self.embedding_type == "text": + for text in raw_inputs: + input_ids = self.tokenizer([text], return_tensors="np")["input_ids"] + processed.append(input_ids) + elif self.embedding_type == "vision": + for img in raw_inputs: + pixel_values = self._preprocess_image(img) + processed.append( + pixel_values[np.newaxis, ...] + ) # Add batch dim: (1, 3, 512, 512) + else: + raise ValueError( + f"Invalid embedding_type: {self.embedding_type}. Must be 'text' or 'vision'." + ) + return processed + + def _postprocess_outputs(self, outputs): + """ + Process ONNX model outputs, truncating each embedding in the array to truncate_dim. + - outputs: NumPy array of embeddings. + - Returns: List of truncated embeddings. + """ + # size of vector in database + truncate_dim = 768 + + # jina v2 defaults to 1024 and uses Matryoshka representation, so + # truncating only causes an extremely minor decrease in retrieval accuracy + if outputs.shape[-1] > truncate_dim: + outputs = outputs[..., :truncate_dim] + + return outputs + + def __call__( + self, inputs: list[str] | list[Image.Image] | list[str], embedding_type=None + ) -> list[np.ndarray]: + self.embedding_type = embedding_type + if not self.embedding_type: + raise ValueError( + "embedding_type must be specified either in __init__ or __call__" + ) + + self._load_model_and_utils() + processed = self._preprocess_inputs(inputs) + batch_size = len(processed) + + # Prepare ONNX inputs with matching batch sizes + onnx_inputs = {} + if self.embedding_type == "text": + onnx_inputs["input_ids"] = np.stack([x[0] for x in processed]) + onnx_inputs["pixel_values"] = np.zeros( + (batch_size, 3, 512, 512), dtype=np.float32 + ) + elif self.embedding_type == "vision": + onnx_inputs["input_ids"] = np.zeros((batch_size, 16), dtype=np.int64) + onnx_inputs["pixel_values"] = np.stack([x[0] for x in processed]) + else: + raise ValueError("Invalid embedding type") + + # Run inference + outputs = self.runner.run(onnx_inputs) + if self.embedding_type == "text": + embeddings = outputs[2] # text embeddings + elif self.embedding_type == "vision": + embeddings = outputs[3] # image embeddings + else: + raise ValueError("Invalid embedding type") + + embeddings = self._postprocess_outputs(embeddings) + return [embedding for embedding in embeddings] diff --git a/frigate/embeddings/onnx/runner.py b/frigate/embeddings/onnx/runner.py index d380f45c1..c785c28f1 100644 --- a/frigate/embeddings/onnx/runner.py +++ b/frigate/embeddings/onnx/runner.py @@ -66,14 +66,9 @@ class ONNXModelRunner: def run(self, input: dict[str, Any]) -> Any: if self.type == "ov": infer_request = self.interpreter.create_infer_request() - input_tensor = list(input.values()) - if len(input_tensor) == 1: - input_tensor = ov.Tensor(array=input_tensor[0]) - else: - input_tensor = ov.Tensor(array=input_tensor) + outputs = infer_request.infer(input) - infer_request.infer(input_tensor) - return [infer_request.get_output_tensor().data] + return outputs elif self.type == "ort": return self.ort.run(None, input) diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index c005c43c2..af23c18f4 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -267,20 +267,41 @@ export default function Explore() { // model states - const { payload: textModelState } = useModelState( - "jinaai/jina-clip-v1-text_model_fp16.onnx", - ); - const { payload: textTokenizerState } = useModelState( - "jinaai/jina-clip-v1-tokenizer", - ); - const modelFile = - config?.semantic_search.model_size === "large" - ? "jinaai/jina-clip-v1-vision_model_fp16.onnx" - : "jinaai/jina-clip-v1-vision_model_quantized.onnx"; + const modelVersion = config?.semantic_search.model || "jinav1"; + const modelSize = config?.semantic_search.model_size || "small"; - const { payload: visionModelState } = useModelState(modelFile); + // Text model state + const { payload: textModelState } = useModelState( + modelVersion === "jinav1" + ? "jinaai/jina-clip-v1-text_model_fp16.onnx" + : modelSize === "large" + ? "jinaai/jina-clip-v2-model_fp16.onnx" + : "jinaai/jina-clip-v2-model_quantized.onnx", + ); + + // Tokenizer state + const { payload: textTokenizerState } = useModelState( + modelVersion === "jinav1" + ? "jinaai/jina-clip-v1-tokenizer" + : "jinaai/jina-clip-v2-tokenizer", + ); + + // Vision model state (same as text model for jinav2) + const visionModelFile = + modelVersion === "jinav1" + ? modelSize === "large" + ? "jinaai/jina-clip-v1-vision_model_fp16.onnx" + : "jinaai/jina-clip-v1-vision_model_quantized.onnx" + : modelSize === "large" + ? "jinaai/jina-clip-v2-model_fp16.onnx" + : "jinaai/jina-clip-v2-model_quantized.onnx"; + const { payload: visionModelState } = useModelState(visionModelFile); + + // Preprocessor/feature extractor state const { payload: visionFeatureExtractorState } = useModelState( - "jinaai/jina-clip-v1-preprocessor_config.json", + modelVersion === "jinav1" + ? "jinaai/jina-clip-v1-preprocessor_config.json" + : "jinaai/jina-clip-v2-preprocessor_config.json", ); const allModelsLoaded = useMemo(() => { diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 263883976..d021fde0f 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -20,6 +20,7 @@ export interface BirdseyeConfig { width: number; } +export type SearchModel = "jinav1" | "jinav2"; export type SearchModelSize = "small" | "large"; export interface CameraConfig { @@ -458,6 +459,7 @@ export interface FrigateConfig { semantic_search: { enabled: boolean; reindex: boolean; + model: SearchModel; model_size: SearchModelSize; }; From 4f855f82ea440d7a5f24eba0f93f5d633c251a86 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 26 Feb 2025 13:39:19 -0700 Subject: [PATCH 03/82] Simplify tensorrt (#16835) * Remove unneccessary trt wheels build * Cleanup * Try without local cuda * Keep specific cuda libs only * Cleanup * Add newer libcufft * remove target * Include more --- docker/tensorrt/Dockerfile.amd64 | 20 +++++++------------ docker/tensorrt/Dockerfile.base | 7 ++++++- .../etc/ld.so.conf.d/cuda_tensorrt.conf | 2 +- docker/tensorrt/requirements-amd64.txt | 1 + docker/tensorrt/trt.hcl | 1 - 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/docker/tensorrt/Dockerfile.amd64 b/docker/tensorrt/Dockerfile.amd64 index 6be11c210..e6429aa90 100644 --- a/docker/tensorrt/Dockerfile.amd64 +++ b/docker/tensorrt/Dockerfile.amd64 @@ -3,22 +3,16 @@ # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND=noninteractive -# Make this a separate target so it can be built/cached optionally -FROM wheels as trt-wheels -ARG DEBIAN_FRONTEND -ARG TARGETARCH -RUN python3 -m pip config set global.break-system-packages true - -# Add TensorRT wheels to another folder -COPY docker/tensorrt/requirements-amd64.txt /requirements-tensorrt.txt -RUN mkdir -p /trt-wheels && pip3 wheel --wheel-dir=/trt-wheels -r /requirements-tensorrt.txt +# Globally set pip break-system-packages option to avoid having to specify it every time +ARG PIP_BREAK_SYSTEM_PACKAGES=1 FROM tensorrt-base AS frigate-tensorrt +ARG PIP_BREAK_SYSTEM_PACKAGES ENV TRT_VER=8.6.1 -RUN python3 -m pip config set global.break-system-packages true -RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \ - pip3 install -U /deps/trt-wheels/*.whl && \ - ldconfig + +# Install TensorRT wheels +COPY docker/tensorrt/requirements-amd64.txt /requirements-tensorrt.txt +RUN pip3 install -U -r /requirements-tensorrt.txt && ldconfig WORKDIR /opt/frigate/ COPY --from=rootfs / / diff --git a/docker/tensorrt/Dockerfile.base b/docker/tensorrt/Dockerfile.base index f9cdde587..6d8d9591b 100644 --- a/docker/tensorrt/Dockerfile.base +++ b/docker/tensorrt/Dockerfile.base @@ -22,9 +22,14 @@ FROM deps AS tensorrt-base #Disable S6 Global timeout ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 +# COPY TensorRT Model Generation Deps COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos -COPY --from=trt-deps /usr/local/cuda-12.* /usr/local/cuda + +# COPY Individual CUDA deps +COPY --from=trt-deps /usr/local/cuda-12.3/targets/x86_64-linux/lib/libcurand.so.* /usr/local/cuda/ +COPY --from=trt-deps /usr/local/cuda-12.3/targets/x86_64-linux/lib/libnvrtc.so.* /usr/local/cuda/ + COPY docker/tensorrt/detector/rootfs/ / ENV YOLO_MODELS="" diff --git a/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf b/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf index 561b7bcd4..72eec56e0 100644 --- a/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf +++ b/docker/tensorrt/detector/rootfs/etc/ld.so.conf.d/cuda_tensorrt.conf @@ -1,5 +1,5 @@ /usr/local/lib -/usr/local/cuda/lib64 +/usr/local/cuda /usr/local/lib/python3.11/dist-packages/nvidia/cudnn/lib /usr/local/lib/python3.11/dist-packages/nvidia/cuda_runtime/lib /usr/local/lib/python3.11/dist-packages/nvidia/cublas/lib diff --git a/docker/tensorrt/requirements-amd64.txt b/docker/tensorrt/requirements-amd64.txt index 8d520d9f9..0e003ca3d 100644 --- a/docker/tensorrt/requirements-amd64.txt +++ b/docker/tensorrt/requirements-amd64.txt @@ -11,6 +11,7 @@ nvidia-cublas-cu11 == 11.11.3.6; platform_machine == 'x86_64' nvidia-cudnn-cu11 == 8.6.0.*; platform_machine == 'x86_64' nvidia-cudnn-cu12 == 9.5.0.*; platform_machine == 'x86_64' nvidia-cufft-cu11==10.*; platform_machine == 'x86_64' +nvidia-cufft-cu12==11.*; platform_machine == 'x86_64' onnx==1.16.*; platform_machine == 'x86_64' onnxruntime-gpu==1.20.*; platform_machine == 'x86_64' protobuf==3.20.3; platform_machine == 'x86_64' diff --git a/docker/tensorrt/trt.hcl b/docker/tensorrt/trt.hcl index ba3b93244..80757ba6d 100644 --- a/docker/tensorrt/trt.hcl +++ b/docker/tensorrt/trt.hcl @@ -95,7 +95,6 @@ target "tensorrt" { wget = "target:wget", tensorrt-base = "target:tensorrt-base", rootfs = "target:rootfs" - wheels = "target:wheels" } target = "frigate-tensorrt" inherits = ["_build_args"] From 2b7b5e3f08e982c55eac890cc446bfc6db37c767 Mon Sep 17 00:00:00 2001 From: toperichvania Date: Thu, 27 Feb 2025 16:28:53 +0100 Subject: [PATCH 04/82] Fix incorrect storage usage per camera (#16825) (#16851) --- frigate/record/maintainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/record/maintainer.py b/frigate/record/maintainer.py index faa41f75f..1cabbfdda 100644 --- a/frigate/record/maintainer.py +++ b/frigate/record/maintainer.py @@ -473,7 +473,7 @@ class RecordingMaintainer(threading.Thread): # get the segment size of the cache file # file without faststart is same size segment_size = round( - float(os.path.getsize(cache_path)) / pow(2, 20), 1 + float(os.path.getsize(cache_path)) / pow(2, 20), 2 ) except OSError: segment_size = 0 From f221a7ae74573b60898a863de86c0161cfe0511b Mon Sep 17 00:00:00 2001 From: Jared Date: Thu, 27 Feb 2025 09:45:32 -0700 Subject: [PATCH 05/82] Quality of life documentation updates (#16852) * Update getting_started with full host:container syntax for hwacc * Update edgetpu.md Add a tip about the coral TPU not changing identification until after Frigate runs an inference on the TPU. --- docs/docs/guides/getting_started.md | 2 +- docs/docs/troubleshooting/edgetpu.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index bb880b8f0..ed2cfb4f4 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -177,7 +177,7 @@ services: frigate: ... devices: - - /dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware + - /dev/dri/renderD128:/dev/dri/renderD128 # for intel hwaccel, needs to be updated for your hardware ... ``` diff --git a/docs/docs/troubleshooting/edgetpu.md b/docs/docs/troubleshooting/edgetpu.md index 2e10f0839..90006c41e 100644 --- a/docs/docs/troubleshooting/edgetpu.md +++ b/docs/docs/troubleshooting/edgetpu.md @@ -10,6 +10,12 @@ There are many possible causes for a USB coral not being detected and some are O 1. When the device is first plugged in and has not initialized it will appear as `1a6e:089a Global Unichip Corp.` when running `lsusb` or checking the hardware page in HA OS. 2. Once initialized, the device will appear as `18d1:9302 Google Inc.` when running `lsusb` or checking the hardware page in HA OS. +:::tip + +Using `lsusb` or checking the hardware page in HA OS will show as `1a6e:089a Global Unichip Corp.` until Frigate runs an inferance using the coral. So don't worry about the identification until after Frigate has attempted to detect the coral. + +::: + If the coral does not initialize then Frigate can not interface with it. Some common reasons for the USB based Coral not initializing are: ### Not Enough Power From db4152c4cab10decbc81eeaf3111f99a76e38e14 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 27 Feb 2025 16:24:03 -0700 Subject: [PATCH 06/82] Fix jetson (#16854) * Fix jetson build * Update ci.yml * Update Dockerfile.base * Update Dockerfile.base * Update Dockerfile.base * Fix * Update ci.yml --- docker/tensorrt/Dockerfile.base | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docker/tensorrt/Dockerfile.base b/docker/tensorrt/Dockerfile.base index 6d8d9591b..5ae018773 100644 --- a/docker/tensorrt/Dockerfile.base +++ b/docker/tensorrt/Dockerfile.base @@ -16,8 +16,16 @@ RUN apt-get update \ RUN --mount=type=bind,source=docker/tensorrt/detector/tensorrt_libyolo.sh,target=/tensorrt_libyolo.sh \ /tensorrt_libyolo.sh +# COPY required individual CUDA deps +RUN mkdir -p /usr/local/cuda-deps +RUN if [ "$TARGETARCH" = "amd64" ]; then \ + cp /usr/local/cuda-12.3/targets/x86_64-linux/lib/libcurand.so.* /usr/local/cuda-deps/ && \ + cp /usr/local/cuda-12.3/targets/x86_64-linux/lib/libnvrtc.so.* /usr/local/cuda-deps/ ; \ + fi + # Frigate w/ TensorRT Support as separate image FROM deps AS tensorrt-base +ARG TARGETARCH #Disable S6 Global timeout ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 @@ -26,9 +34,8 @@ ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos -# COPY Individual CUDA deps -COPY --from=trt-deps /usr/local/cuda-12.3/targets/x86_64-linux/lib/libcurand.so.* /usr/local/cuda/ -COPY --from=trt-deps /usr/local/cuda-12.3/targets/x86_64-linux/lib/libnvrtc.so.* /usr/local/cuda/ +# COPY Individual CUDA deps folder +COPY --from=trt-deps /usr/local/cuda-deps /usr/local/cuda COPY docker/tensorrt/detector/rootfs/ / ENV YOLO_MODELS="" From 8d2f461350ed6f1881eee15d7089014e24a9b0ed Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 28 Feb 2025 12:43:08 -0600 Subject: [PATCH 07/82] Embeddings tweaks (#16864) * make semantic search optional * config * frontend metrics * docs * tweak * fixes * also check genai cameras for embeddings context --- docs/docs/configuration/face_recognition.md | 5 +- docs/docs/configuration/genai.md | 6 -- docs/docs/configuration/reference.md | 1 - frigate/api/event.py | 5 +- frigate/app.py | 30 +++++++++- frigate/config/config.py | 11 ---- frigate/embeddings/__init__.py | 4 -- frigate/embeddings/maintainer.py | 65 ++++++++++++--------- frigate/stats/util.py | 29 +++++---- web/src/pages/System.tsx | 6 +- web/src/types/frigateConfig.ts | 4 ++ 11 files changed, 95 insertions(+), 71 deletions(-) diff --git a/docs/docs/configuration/face_recognition.md b/docs/docs/configuration/face_recognition.md index aaab92e6d..4d934afce 100644 --- a/docs/docs/configuration/face_recognition.md +++ b/docs/docs/configuration/face_recognition.md @@ -9,7 +9,7 @@ Frigate has support for CV2 Local Binary Pattern Face Recognizer to recognize fa ## Configuration -Face recognition is disabled by default and requires semantic search to be enabled, face recognition must be enabled in your config file before it can be used. Semantic Search and face recognition are global configuration settings. +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. ```yaml face_recognition: @@ -36,6 +36,7 @@ The accuracy of face recognition is heavily dependent on the quality of data giv :::tip When choosing images to include in the face training set it is recommended to always follow these recommendations: + - If it is difficult to make out details in a persons face it will not be helpful in training. - Avoid images with under/over-exposure. - Avoid blurry / pixelated images. @@ -52,4 +53,4 @@ Then it is recommended to use the `Face Library` tab in Frigate to select and tr ### Step 2 - Expanding The Dataset -Once straight-on images are performing well, start choosing slightly off-angle images to include for training. It is important to still choose images where enough face detail is visible to recognize someone. \ No newline at end of file +Once straight-on images are performing well, start choosing slightly off-angle images to include for training. It is important to still choose images where enough face detail is visible to recognize someone. diff --git a/docs/docs/configuration/genai.md b/docs/docs/configuration/genai.md index 23f1c06be..e46107a82 100644 --- a/docs/docs/configuration/genai.md +++ b/docs/docs/configuration/genai.md @@ -7,12 +7,6 @@ Generative AI can be used to automatically generate descriptive text based on th Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle. Descriptions can also be regenerated manually via the Frigate UI. -:::info - -Semantic Search must be enabled to use Generative AI. - -::: - ## Configuration Generative AI can be enabled for all cameras or only for specific cameras. There are currently 3 native providers available to integrate with Frigate. Other providers that support the OpenAI standard API can also be used. See the OpenAI section below. diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index c64272214..b53d9268f 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -570,7 +570,6 @@ lpr: known_plates: {} # Optional: Configuration for AI generated tracked object descriptions -# NOTE: Semantic Search must be enabled for this to do anything. # WARNING: Depending on the provider, this will send thumbnails over the internet # to Google or OpenAI's LLMs to generate descriptions. It can be overridden at # the camera level (enabled: False) to enhance privacy for indoor cameras. diff --git a/frigate/api/event.py b/frigate/api/event.py index bb1bf7395..9a5578bae 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -1083,10 +1083,7 @@ def regenerate_description( camera_config = request.app.frigate_config.cameras[event.camera] - if ( - request.app.frigate_config.semantic_search.enabled - and camera_config.genai.enabled - ): + if camera_config.genai.enabled: request.app.event_metadata_updater.publish((event.id, params.source)) return JSONResponse( diff --git a/frigate/app.py b/frigate/app.py index 400d4bca0..8b63ab0a0 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -93,7 +93,13 @@ class FrigateApp: self.log_queue: Queue = mp.Queue() self.camera_metrics: dict[str, CameraMetrics] = {} self.embeddings_metrics: DataProcessorMetrics | None = ( - DataProcessorMetrics() if config.semantic_search.enabled else None + DataProcessorMetrics() + if ( + config.semantic_search.enabled + or config.lpr.enabled + or config.face_recognition.enabled + ) + else None ) self.ptz_metrics: dict[str, PTZMetrics] = {} self.processes: dict[str, int] = {} @@ -236,7 +242,16 @@ class FrigateApp: logger.info(f"Review process started: {review_segment_process.pid}") def init_embeddings_manager(self) -> None: - if not self.config.semantic_search.enabled: + genai_cameras = [ + c for c in self.config.cameras.values() if c.enabled and c.genai.enabled + ] + + if ( + not self.config.semantic_search.enabled + and not genai_cameras + and not self.config.lpr.enabled + and not self.config.face_recognition.enabled + ): return embedding_process = util.Process( @@ -293,7 +308,16 @@ class FrigateApp: migrate_exports(self.config.ffmpeg, list(self.config.cameras.keys())) def init_embeddings_client(self) -> None: - if self.config.semantic_search.enabled: + genai_cameras = [ + c for c in self.config.cameras.values() if c.enabled and c.genai.enabled + ] + + if ( + self.config.semantic_search.enabled + or self.config.lpr.enabled + or genai_cameras + or self.config.face_recognition.enabled + ): # Create a client for other processes to use self.embeddings = EmbeddingsContext(self.db) diff --git a/frigate/config/config.py b/frigate/config/config.py index 39ee31411..d2ca9a6f5 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -172,16 +172,6 @@ class RestreamConfig(BaseModel): model_config = ConfigDict(extra="allow") -def verify_semantic_search_dependent_configs(config: FrigateConfig) -> None: - """Verify that semantic search is enabled if required features are enabled.""" - if not config.semantic_search.enabled: - if config.genai.enabled: - raise ValueError("Genai requires semantic search to be enabled.") - - if config.face_recognition.enabled: - raise ValueError("Face recognition requires semantic to be enabled.") - - def verify_config_roles(camera_config: CameraConfig) -> None: """Verify that roles are setup in the config correctly.""" assigned_roles = list( @@ -647,7 +637,6 @@ class FrigateConfig(FrigateBaseModel): detector_config.model = model self.detectors[key] = detector_config - verify_semantic_search_dependent_configs(self) return self @field_validator("cameras") diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index 18673c4e9..56bd097d6 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -28,10 +28,6 @@ logger = logging.getLogger(__name__) def manage_embeddings(config: FrigateConfig, metrics: DataProcessorMetrics) -> None: - # Only initialize embeddings if semantic search is enabled - if not config.semantic_search.enabled: - return - stop_event = mp.Event() def receiveSignal(signalNumber: int, frame: Optional[FrameType]) -> None: diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index a18ca7a7f..c9b6062c9 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -71,11 +71,14 @@ class EmbeddingMaintainer(threading.Thread): super().__init__(name="embeddings_maintainer") self.config = config self.metrics = metrics - self.embeddings = Embeddings(config, db, metrics) + self.embeddings = None - # Check if we need to re-index events - if config.semantic_search.reindex: - self.embeddings.reindex() + if config.semantic_search.enabled: + self.embeddings = Embeddings(config, db, metrics) + + # Check if we need to re-index events + if config.semantic_search.reindex: + self.embeddings.reindex() # create communication for updating event descriptions self.requestor = InterProcessRequestor() @@ -152,30 +155,30 @@ class EmbeddingMaintainer(threading.Thread): def _handle_request(topic: str, data: dict[str, any]) -> str: try: - if topic == EmbeddingsRequestEnum.embed_description.value: - return serialize( - self.embeddings.embed_description( - data["id"], data["description"] - ), - pack=False, - ) - elif topic == EmbeddingsRequestEnum.embed_thumbnail.value: - thumbnail = base64.b64decode(data["thumbnail"]) - return serialize( - self.embeddings.embed_thumbnail(data["id"], thumbnail), - pack=False, - ) - elif topic == EmbeddingsRequestEnum.generate_search.value: - return serialize( - self.embeddings.embed_description("", data, upsert=False), - pack=False, - ) - else: - processors = [self.realtime_processors, self.post_processors] - for processor_list in processors: - for processor in processor_list: - resp = processor.handle_request(topic, data) - + # First handle the embedding-specific topics when semantic search is enabled + if self.config.semantic_search.enabled: + if topic == EmbeddingsRequestEnum.embed_description.value: + return serialize( + self.embeddings.embed_description( + data["id"], data["description"] + ), + pack=False, + ) + elif topic == EmbeddingsRequestEnum.embed_thumbnail.value: + thumbnail = base64.b64decode(data["thumbnail"]) + return serialize( + self.embeddings.embed_thumbnail(data["id"], thumbnail), + pack=False, + ) + elif topic == EmbeddingsRequestEnum.generate_search.value: + return serialize( + self.embeddings.embed_description("", data, upsert=False), + pack=False, + ) + processors = [self.realtime_processors, self.post_processors] + for processor_list in processors: + for processor in processor_list: + resp = processor.handle_request(topic, data) if resp is not None: return resp except Exception as e: @@ -432,6 +435,9 @@ class EmbeddingMaintainer(threading.Thread): def _embed_thumbnail(self, event_id: str, thumbnail: bytes) -> None: """Embed the thumbnail for an event.""" + if not self.config.semantic_search.enabled: + return + self.embeddings.embed_thumbnail(event_id, thumbnail) def _embed_description(self, event: Event, thumbnails: list[bytes]) -> None: @@ -457,7 +463,8 @@ class EmbeddingMaintainer(threading.Thread): ) # Embed the description - self.embeddings.embed_description(event.id, description) + if self.config.semantic_search.enabled: + self.embeddings.embed_description(event.id, description) logger.debug( "Generated description for %s (%d images): %s", diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 3d836868e..287c384cd 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -282,16 +282,24 @@ def stats_snapshot( } stats["detection_fps"] = round(total_detection_fps, 2) - if config.semantic_search.enabled: - embeddings_metrics = stats_tracking["embeddings_metrics"] - stats["embeddings"] = { - "image_embedding_speed": round( - embeddings_metrics.image_embeddings_fps.value * 1000, 2 - ), - "text_embedding_speed": round( - embeddings_metrics.text_embeddings_sps.value * 1000, 2 - ), - } + stats["embeddings"] = {} + + # Get metrics if available + embeddings_metrics = stats_tracking.get("embeddings_metrics") + + if embeddings_metrics: + # Add metrics based on what's enabled + if config.semantic_search.enabled: + stats["embeddings"].update( + { + "image_embedding_speed": round( + embeddings_metrics.image_embeddings_fps.value * 1000, 2 + ), + "text_embedding_speed": round( + embeddings_metrics.text_embeddings_sps.value * 1000, 2 + ), + } + ) if config.face_recognition.enabled: stats["embeddings"]["face_recognition_speed"] = round( @@ -302,6 +310,7 @@ def stats_snapshot( stats["embeddings"]["plate_recognition_speed"] = round( embeddings_metrics.alpr_pps.value * 1000, 2 ) + if "license_plate" not in config.objects.all_objects: stats["embeddings"]["yolov9_plate_detection_speed"] = round( embeddings_metrics.yolov9_lpr_fps.value * 1000, 2 diff --git a/web/src/pages/System.tsx b/web/src/pages/System.tsx index 491149be2..05eed5b3e 100644 --- a/web/src/pages/System.tsx +++ b/web/src/pages/System.tsx @@ -28,7 +28,11 @@ function System() { const metrics = useMemo(() => { const metrics = [...allMetrics]; - if (!config?.semantic_search.enabled) { + if ( + !config?.semantic_search.enabled && + !config?.lpr.enabled && + !config?.face_recognition.enabled + ) { const index = metrics.indexOf("features"); metrics.splice(index, 1); } diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index d021fde0f..4ec4de853 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -363,6 +363,10 @@ export interface FrigateConfig { camera_groups: { [groupName: string]: CameraGroupConfig }; + lpr: { + enabled: boolean; + }; + logger: { default: string; logs: Record; From 06d6e21de813f3cd8665a82d004cd9d0138a2423 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 28 Feb 2025 13:48:08 -0700 Subject: [PATCH 08/82] Fix cuda targetarch (#16869) --- docker/tensorrt/Dockerfile.base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/tensorrt/Dockerfile.base b/docker/tensorrt/Dockerfile.base index 5ae018773..4305f1d74 100644 --- a/docker/tensorrt/Dockerfile.base +++ b/docker/tensorrt/Dockerfile.base @@ -8,6 +8,7 @@ ARG TRT_BASE=nvcr.io/nvidia/tensorrt:23.12-py3 # Build TensorRT-specific library FROM ${TRT_BASE} AS trt-deps +ARG TARGETARCH ARG COMPUTE_LEVEL RUN apt-get update \ @@ -25,7 +26,6 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \ # Frigate w/ TensorRT Support as separate image FROM deps AS tensorrt-base -ARG TARGETARCH #Disable S6 Global timeout ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 From 458134de5d02002084e65c0b5301a3450dbac9b8 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 1 Mar 2025 05:35:09 +0100 Subject: [PATCH 09/82] Reuse constants (#16874) --- frigate/api/media.py | 7 +++++-- frigate/api/preview.py | 4 ++-- frigate/const.py | 1 + frigate/data_processing/real_time/face.py | 6 ++++-- frigate/detectors/detector_config.py | 4 ++-- frigate/detectors/plugins/hailo8l.py | 3 ++- frigate/detectors/plugins/openvino.py | 7 +++++-- frigate/detectors/plugins/rknn.py | 3 ++- frigate/detectors/plugins/rocm.py | 3 ++- frigate/embeddings/onnx/runner.py | 4 +++- frigate/output/birdseye.py | 6 ++++-- frigate/test/http_api/base_http_test.py | 7 ++++--- frigate/test/test_config.py | 4 ++-- frigate/test/test_http.py | 7 ++++--- frigate/util/config.py | 2 +- frigate/util/model.py | 17 ++++++++++++----- 16 files changed, 55 insertions(+), 30 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index 74e9e7aaa..e3f74ea98 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -31,6 +31,7 @@ from frigate.config import FrigateConfig from frigate.const import ( CACHE_DIR, CLIPS_DIR, + INSTALL_DIR, MAX_SEGMENT_DURATION, PREVIEW_FRAME_TYPE, RECORD_DIR, @@ -155,7 +156,9 @@ def latest_frame( frame_processor.get_current_frame_time(camera_name) + retry_interval ): if request.app.camera_error_image is None: - error_image = glob.glob("/opt/frigate/frigate/images/camera-error.jpg") + error_image = glob.glob( + os.path.join(INSTALL_DIR, "frigate/images/camera-error.jpg") + ) if len(error_image) > 0: request.app.camera_error_image = cv2.imread( @@ -550,7 +553,7 @@ def recording_clip( ) file_name = sanitize_filename(f"playlist_{camera_name}_{start_ts}-{end_ts}.txt") - file_path = f"/tmp/cache/{file_name}" + file_path = os.path.join(CACHE_DIR, file_name) with open(file_path, "w") as file: clip: Recordings for clip in recordings: diff --git a/frigate/api/preview.py b/frigate/api/preview.py index d14a15ff1..2db2326ab 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -9,7 +9,7 @@ from fastapi import APIRouter from fastapi.responses import JSONResponse from frigate.api.defs.tags import Tags -from frigate.const import CACHE_DIR, PREVIEW_FRAME_TYPE +from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.models import Previews logger = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def preview_ts(camera_name: str, start_ts: float, end_ts: float): clips.append( { "camera": preview["camera"], - "src": preview["path"].replace("/media/frigate", ""), + "src": preview["path"].replace(BASE_DIR, ""), "type": "video/mp4", "start": preview["start_time"], "end": preview["end_time"], diff --git a/frigate/const.py b/frigate/const.py index 866fa3d29..ffd1ca406 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -1,6 +1,7 @@ import os import re +INSTALL_DIR = "/opt/frigate" CONFIG_DIR = "/config" DEFAULT_DB_PATH = f"{CONFIG_DIR}/frigate.db" MODEL_CACHE_DIR = f"{CONFIG_DIR}/model_cache" diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index d2b677653..e7cf622e9 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -76,14 +76,16 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): def __build_detector(self) -> None: self.face_detector = cv2.FaceDetectorYN.create( - "/config/model_cache/facedet/facedet.onnx", + os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"), config="", input_size=(320, 320), score_threshold=0.8, nms_threshold=0.3, ) self.landmark_detector = cv2.face.createFacemarkLBF() - self.landmark_detector.loadModel("/config/model_cache/facedet/landmarkdet.yaml") + self.landmark_detector.loadModel( + os.path.join(MODEL_CACHE_DIR, "facedet/landmarkdet.yaml") + ) def __build_classifier(self) -> None: if not self.landmark_detector: diff --git a/frigate/detectors/detector_config.py b/frigate/detectors/detector_config.py index 16599b141..fceab5a19 100644 --- a/frigate/detectors/detector_config.py +++ b/frigate/detectors/detector_config.py @@ -9,7 +9,7 @@ import requests from pydantic import BaseModel, ConfigDict, Field from pydantic.fields import PrivateAttr -from frigate.const import DEFAULT_ATTRIBUTE_LABEL_MAP +from frigate.const import DEFAULT_ATTRIBUTE_LABEL_MAP, MODEL_CACHE_DIR from frigate.plus import PlusApi from frigate.util.builtin import generate_color_palette, load_labels @@ -123,7 +123,7 @@ class ModelConfig(BaseModel): return model_id = self.path[7:] - self.path = f"/config/model_cache/{model_id}" + self.path = os.path.join(MODEL_CACHE_DIR, model_id) model_info_path = f"{self.path}.json" # download the model if it doesn't exist diff --git a/frigate/detectors/plugins/hailo8l.py b/frigate/detectors/plugins/hailo8l.py index b66d78bd6..69e86bc5b 100644 --- a/frigate/detectors/plugins/hailo8l.py +++ b/frigate/detectors/plugins/hailo8l.py @@ -22,6 +22,7 @@ except ModuleNotFoundError: from pydantic import BaseModel, Field from typing_extensions import Literal +from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig @@ -57,7 +58,7 @@ class HailoDetector(DetectionApi): self.h8l_tensor_format = detector_config.model.input_tensor self.h8l_pixel_format = detector_config.model.input_pixel_format self.model_url = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.11.0/hailo8l/ssd_mobilenet_v1.hef" - self.cache_dir = "/config/model_cache/h8l_cache" + self.cache_dir = os.path.join(MODEL_CACHE_DIR, "h8l_cache") self.expected_model_filename = "ssd_mobilenet_v1.hef" output_type = "FLOAT32" diff --git a/frigate/detectors/plugins/openvino.py b/frigate/detectors/plugins/openvino.py index 27be6b9bd..0f0b99a1f 100644 --- a/frigate/detectors/plugins/openvino.py +++ b/frigate/detectors/plugins/openvino.py @@ -7,6 +7,7 @@ import openvino.properties as props from pydantic import Field from typing_extensions import Literal +from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum from frigate.util.model import post_process_yolov9 @@ -41,8 +42,10 @@ class OvDetector(DetectionApi): logger.error(f"OpenVino model file {detector_config.model.path} not found.") raise FileNotFoundError - os.makedirs("/config/model_cache/openvino", exist_ok=True) - self.ov_core.set_property({props.cache_dir: "/config/model_cache/openvino"}) + os.makedirs(os.path.join(MODEL_CACHE_DIR, "openvino"), exist_ok=True) + self.ov_core.set_property( + {props.cache_dir: os.path.join(MODEL_CACHE_DIR, "openvino")} + ) self.interpreter = self.ov_core.compile_model( model=detector_config.model.path, device_name=detector_config.device ) diff --git a/frigate/detectors/plugins/rknn.py b/frigate/detectors/plugins/rknn.py index bfd7866e6..407c93917 100644 --- a/frigate/detectors/plugins/rknn.py +++ b/frigate/detectors/plugins/rknn.py @@ -6,6 +6,7 @@ from typing import Literal from pydantic import Field +from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum @@ -17,7 +18,7 @@ supported_socs = ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"] supported_models = {ModelTypeEnum.yolonas: "^deci-fp16-yolonas_[sml]$"} -model_cache_dir = "/config/model_cache/rknn_cache/" +model_cache_dir = os.path.join(MODEL_CACHE_DIR, "rknn_cache/") class RknnDetectorConfig(BaseDetectorConfig): diff --git a/frigate/detectors/plugins/rocm.py b/frigate/detectors/plugins/rocm.py index 60118d129..7c87edb50 100644 --- a/frigate/detectors/plugins/rocm.py +++ b/frigate/detectors/plugins/rocm.py @@ -9,6 +9,7 @@ import numpy as np from pydantic import Field from typing_extensions import Literal +from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi from frigate.detectors.detector_config import ( BaseDetectorConfig, @@ -116,7 +117,7 @@ class ROCmDetector(DetectionApi): logger.info(f"AMD/ROCm: saving parsed model into {mxr_path}") - os.makedirs("/config/model_cache/rocm", exist_ok=True) + os.makedirs(os.path.join(MODEL_CACHE_DIR, "rocm"), exist_ok=True) migraphx.save(self.model, mxr_path) logger.info("AMD/ROCm: model loaded") diff --git a/frigate/embeddings/onnx/runner.py b/frigate/embeddings/onnx/runner.py index c785c28f1..7badae325 100644 --- a/frigate/embeddings/onnx/runner.py +++ b/frigate/embeddings/onnx/runner.py @@ -1,10 +1,12 @@ """Convenience runner for onnx models.""" import logging +import os.path from typing import Any import onnxruntime as ort +from frigate.const import MODEL_CACHE_DIR from frigate.util.model import get_ort_providers try: @@ -32,7 +34,7 @@ class ONNXModelRunner: self.type = "ov" self.ov = ov.Core() self.ov.set_property( - {ov.properties.cache_dir: "/config/model_cache/openvino"} + {ov.properties.cache_dir: os.path.join(MODEL_CACHE_DIR, "openvino")} ) self.interpreter = self.ov.compile_model( model=model_path, device_name=device diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 00f17c8f4..8331eb64a 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -16,7 +16,7 @@ import numpy as np from frigate.comms.config_updater import ConfigSubscriber from frigate.config import BirdseyeModeEnum, FfmpegConfig, FrigateConfig -from frigate.const import BASE_DIR, BIRDSEYE_PIPE +from frigate.const import BASE_DIR, BIRDSEYE_PIPE, INSTALL_DIR from frigate.util.image import ( SharedMemoryFrameManager, copy_yuv_to_position, @@ -297,7 +297,9 @@ class BirdsEyeFrameManager: birdseye_logo = cv2.imread(custom_logo_files[0], cv2.IMREAD_UNCHANGED) if birdseye_logo is None: - logo_files = glob.glob("/opt/frigate/frigate/images/birdseye.png") + logo_files = glob.glob( + os.path.join(INSTALL_DIR, "frigate/images/birdseye.png") + ) if len(logo_files) > 0: birdseye_logo = cv2.imread(logo_files[0], cv2.IMREAD_UNCHANGED) diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index c16ab9926..f5a0aca3c 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -10,6 +10,7 @@ from pydantic import Json from frigate.api.fastapi_app import create_fastapi_app from frigate.config import FrigateConfig +from frigate.const import BASE_DIR, CACHE_DIR from frigate.models import Event, Recordings, ReviewSegment from frigate.review.types import SeverityEnum from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS @@ -73,19 +74,19 @@ class BaseTestHttp(unittest.TestCase): "total": 67.1, "used": 16.6, }, - "/media/frigate/clips": { + os.path.join(BASE_DIR, "clips"): { "free": 42429.9, "mount_type": "ext4", "total": 244529.7, "used": 189607.0, }, - "/media/frigate/recordings": { + os.path.join(BASE_DIR, "recordings"): { "free": 0.2, "mount_type": "ext4", "total": 8.0, "used": 7.8, }, - "/tmp/cache": { + CACHE_DIR: { "free": 976.8, "mount_type": "tmpfs", "total": 1000.0, diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index e6cb1274e..5a3deefda 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -854,9 +854,9 @@ class TestConfig(unittest.TestCase): assert frigate_config.model.merged_labelmap[0] == "person" def test_plus_labelmap(self): - with open("/config/model_cache/test", "w") as f: + with open(os.path.join(MODEL_CACHE_DIR, "test"), "w") as f: json.dump(self.plus_model_info, f) - with open("/config/model_cache/test.json", "w") as f: + with open(os.path.join(MODEL_CACHE_DIR, "test.json"), "w") as f: json.dump(self.plus_model_info, f) config = { diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 46de1307f..0238c766c 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -11,6 +11,7 @@ from playhouse.sqliteq import SqliteQueueDatabase from frigate.api.fastapi_app import create_fastapi_app from frigate.config import FrigateConfig +from frigate.const import BASE_DIR, CACHE_DIR from frigate.models import Event, Recordings, Timeline from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS @@ -74,19 +75,19 @@ class TestHttp(unittest.TestCase): "total": 67.1, "used": 16.6, }, - "/media/frigate/clips": { + os.path.join(BASE_DIR, "clips"): { "free": 42429.9, "mount_type": "ext4", "total": 244529.7, "used": 189607.0, }, - "/media/frigate/recordings": { + os.path.join(BASE_DIR, "recordings"): { "free": 0.2, "mount_type": "ext4", "total": 8.0, "used": 7.8, }, - "/tmp/cache": { + CACHE_DIR: { "free": 976.8, "mount_type": "tmpfs", "total": 1000.0, diff --git a/frigate/util/config.py b/frigate/util/config.py index 5b40fe37b..1ed82f802 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -14,7 +14,7 @@ from frigate.util.services import get_video_properties logger = logging.getLogger(__name__) CURRENT_CONFIG_VERSION = "0.16-0" -DEFAULT_CONFIG_FILE = "/config/config.yml" +DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml") def find_config_file() -> str: diff --git a/frigate/util/model.py b/frigate/util/model.py index 0428a42ff..d96493ee6 100644 --- a/frigate/util/model.py +++ b/frigate/util/model.py @@ -7,6 +7,8 @@ import cv2 import numpy as np import onnxruntime as ort +from frigate.const import MODEL_CACHE_DIR + logger = logging.getLogger(__name__) @@ -105,7 +107,8 @@ def get_ort_providers( # so it is not enabled by default if device == "Tensorrt": os.makedirs( - "/config/model_cache/tensorrt/ort/trt-engines", exist_ok=True + os.path.join(MODEL_CACHE_DIR, "tensorrt/ort/trt-engines"), + exist_ok=True, ) device_id = 0 if not device.isdigit() else int(device) providers.append(provider) @@ -116,19 +119,23 @@ def get_ort_providers( and os.environ.get("USE_FP_16", "True") != "False", "trt_timing_cache_enable": True, "trt_engine_cache_enable": True, - "trt_timing_cache_path": "/config/model_cache/tensorrt/ort", - "trt_engine_cache_path": "/config/model_cache/tensorrt/ort/trt-engines", + "trt_timing_cache_path": os.path.join( + MODEL_CACHE_DIR, "tensorrt/ort" + ), + "trt_engine_cache_path": os.path.join( + MODEL_CACHE_DIR, "tensorrt/ort/trt-engines" + ), } ) else: continue elif provider == "OpenVINOExecutionProvider": - os.makedirs("/config/model_cache/openvino/ort", exist_ok=True) + os.makedirs(os.path.join(MODEL_CACHE_DIR, "openvino/ort"), exist_ok=True) providers.append(provider) options.append( { "arena_extend_strategy": "kSameAsRequested", - "cache_dir": "/config/model_cache/openvino/ort", + "cache_dir": os.path.join(MODEL_CACHE_DIR, "openvino/ort"), "device_type": device, } ) From f56668e4676232d9cd97b86770ae378e5e9121e4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 1 Mar 2025 16:09:41 -0700 Subject: [PATCH 10/82] Update d-fine documentation (#16881) --- docs/docs/configuration/object_detectors.md | 46 ++++++++++++--------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index bc76779cb..37ce86b07 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -562,30 +562,15 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl #### 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. +[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 YOLO-NAS model for use in Frigate. -To export as ONNX: +:::warning -1. Clone: https://github.com/Peterande/D-FINE and install all dependencies. -2. Select and download a checkpoint from the [readme](https://github.com/Peterande/D-FINE). -3. Modify line 58 of `tools/deployment/export_onnx.py` and change batch size to 1: `data = torch.rand(1, 3, 640, 640)` -4. Run the export, making sure you select the right config, for your checkpoint. - -Example: - -``` -python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_m_obj2coco.yml -r output/dfine_m_obj2coco.pth -``` - -:::tip - -Model export has only been tested on Linux (or WSL2). Not all dependencies are in `requirements.txt`. Some live in the deployment folder, and some are still missing entirely and must be installed manually. - -Make sure you change the batch size to 1 before exporting. +D-FINE is currently not supported on OpenVINO ::: -After placing the downloaded onnx model in your config 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 detectors: @@ -784,6 +769,29 @@ Some model types are not included in Frigate by default. Here are some tips for getting different model types +### Downloading D-FINE Model + +To export as ONNX: + +1. Clone: https://github.com/Peterande/D-FINE and install all dependencies. +2. Select and download a checkpoint from the [readme](https://github.com/Peterande/D-FINE). +3. Modify line 58 of `tools/deployment/export_onnx.py` and change batch size to 1: `data = torch.rand(1, 3, 640, 640)` +4. Run the export, making sure you select the right config, for your checkpoint. + +Example: + +``` +python3 tools/deployment/export_onnx.py -c configs/dfine/objects365/dfine_hgnetv2_m_obj2coco.yml -r output/dfine_m_obj2coco.pth +``` + +:::tip + +Model export has only been tested on Linux (or WSL2). Not all dependencies are in `requirements.txt`. Some live in the deployment folder, and some are still missing entirely and must be installed manually. + +Make sure you change the batch size to 1 before exporting. + +::: + ### Downloading YOLO-NAS Model You can build and download a compatible model with pre-trained weights using [this notebook](https://github.com/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/blakeblackshear/frigate/blob/dev/notebooks/YOLO_NAS_Pretrained_Export.ipynb). From 4e03efaba99d29c57f4ebb8c4ce0ac8df7a93803 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 2 Mar 2025 08:26:59 -0700 Subject: [PATCH 11/82] Disable hailort log (#16888) --- docker/main/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 8dee8e642..674add58e 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -225,6 +225,9 @@ ENV TRANSFORMERS_NO_ADVISORY_WARNINGS=1 # Set OpenCV ffmpeg loglevel to fatal: https://ffmpeg.org/doxygen/trunk/log_8h.html ENV OPENCV_FFMPEG_LOGLEVEL=8 +# Set HailoRT to disable logging +ENV HAILORT_LOGGER_PATH=NONE + ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PATH}" # Install dependencies From b8f4cb5435afee3e9cd87202cd798cf9ce964420 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 2 Mar 2025 09:30:18 -0700 Subject: [PATCH 12/82] Fix docs (#16889) --- docs/docs/configuration/object_detectors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 37ce86b07..531ef5108 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -562,7 +562,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl #### 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 YOLO-NAS 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 From 0128ec2ba60ac5229b8f88c3c271dc6e078da4c4 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 2 Mar 2025 20:46:46 -0700 Subject: [PATCH 13/82] Upgrade RocM to 6.3.3 (#16900) * Simplify rocm install and update to 6.3.1 * Build out more necessary packages * Update to 6.3.3 * Set bake version * Fix typo * Ensure NHWC is used * Reset dev changes * Write to cache --- .github/workflows/ci.yml | 1 + docker/rocm/Dockerfile | 79 +-- docker/rocm/migraphx/CMakeLists.txt | 26 - docker/rocm/migraphx/migraphx_py.cpp | 582 ----------------------- docker/rocm/requirements-wheels-rocm.txt | 2 +- docker/rocm/rocm-pin-600 | 3 - docker/rocm/rocm.hcl | 10 +- docker/rocm/rocm.list | 1 - frigate/detectors/plugins/onnx.py | 2 +- 9 files changed, 36 insertions(+), 670 deletions(-) delete mode 100644 docker/rocm/migraphx/CMakeLists.txt delete mode 100644 docker/rocm/migraphx/migraphx_py.cpp delete mode 100644 docker/rocm/rocm-pin-600 delete mode 100644 docker/rocm/rocm.list diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a666b897..5b787b273 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,6 +175,7 @@ jobs: files: docker/rocm/rocm.hcl set: | rocm.tags=${{ steps.setup.outputs.image-name }}-rocm + *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-rocm,mode=max *.cache-from=type=gha arm64_extra_builds: runs-on: ubuntu-22.04-arm diff --git a/docker/rocm/Dockerfile b/docker/rocm/Dockerfile index 34c7efffb..78f91b96f 100644 --- a/docker/rocm/Dockerfile +++ b/docker/rocm/Dockerfile @@ -2,79 +2,49 @@ # https://askubuntu.com/questions/972516/debian-frontend-environment-variable ARG DEBIAN_FRONTEND=noninteractive -ARG ROCM=5.7.3 +ARG ROCM=6.3.3 ARG AMDGPU=gfx900 ARG HSA_OVERRIDE_GFX_VERSION ARG HSA_OVERRIDE ####################################################################### -FROM ubuntu:focal as rocm +FROM wget AS rocm ARG ROCM +ARG AMDGPU -RUN apt-get update && apt-get -y upgrade -RUN apt-get -y install gnupg wget - -RUN mkdir --parents --mode=0755 /etc/apt/keyrings - -RUN wget https://repo.radeon.com/rocm/rocm.gpg.key -O - | gpg --dearmor | tee /etc/apt/keyrings/rocm.gpg > /dev/null -COPY docker/rocm/rocm.list /etc/apt/sources.list.d/ -COPY docker/rocm/rocm-pin-600 /etc/apt/preferences.d/ - -RUN apt-get update - -RUN apt-get -y install --no-install-recommends migraphx hipfft roctracer -RUN apt-get -y install --no-install-recommends migraphx-dev +RUN apt update && \ + apt install -y wget gpg && \ + wget -O rocm.deb https://repo.radeon.com/amdgpu-install/$ROCM/ubuntu/jammy/amdgpu-install_6.3.60303-1_all.deb && \ + apt install -y ./rocm.deb && \ + apt update && \ + apt install -y rocm RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib -RUN cd /opt/rocm-$ROCM/lib && cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocfft*.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ +RUN cd /opt/rocm-$ROCM/lib && \ + cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocfft*.so* librocprofiler*.so* libroctx*.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ && \ + mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib && \ + cp -dpr migraphx/lib/* /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib RUN cd /opt/rocm-dist/opt/ && ln -s rocm-$ROCM rocm RUN mkdir -p /opt/rocm-dist/etc/ld.so.conf.d/ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf -####################################################################### -FROM --platform=linux/amd64 debian:12 as debian-base - -RUN apt-get update && apt-get -y upgrade -RUN apt-get -y install --no-install-recommends libelf1 libdrm2 libdrm-amdgpu1 libnuma1 kmod - -RUN apt-get -y install python3 - -####################################################################### -# ROCm does not come with migraphx wrappers for python 3.9, so we build it here -FROM debian-base as debian-build - -ARG ROCM - -COPY --from=rocm /opt/rocm-$ROCM /opt/rocm-$ROCM -RUN ln -s /opt/rocm-$ROCM /opt/rocm - -RUN apt-get -y install g++ cmake -RUN apt-get -y install python3-pybind11 python3-distutils python3-dev - -WORKDIR /opt/build - -COPY docker/rocm/migraphx . - -RUN mkdir build && cd build && cmake .. && make install - ####################################################################### FROM deps AS deps-prelim -# need this to install libnuma1 -RUN apt-get update -# no ugprade?!?! -RUN apt-get -y install libnuma1 +RUN apt-get update && apt-get install -y libnuma1 -WORKDIR /opt/frigate/ +WORKDIR /opt/frigate COPY --from=rootfs / / -# Temporarily disabled to see if a new wheel can be built to support py3.11 -#COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt -#RUN python3 -m pip install --upgrade pip \ -# && pip3 uninstall -y onnxruntime-openvino \ -# && pip3 install -r /requirements.txt +RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ + && python3 get-pip.py "pip" --break-system-packages +RUN python3 -m pip config set global.break-system-packages true + +COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt +RUN pip3 uninstall -y onnxruntime-openvino \ + && pip3 install -r /requirements.txt ####################################################################### FROM scratch AS rocm-dist @@ -87,12 +57,11 @@ COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*$AMDGPU* /opt/rocm-$ROCM/share COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx908* /opt/rocm-$ROCM/share/miopen/db/ COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*$AMDGPU* /opt/rocm-$ROCM/lib/rocblas/library/ COPY --from=rocm /opt/rocm-dist/ / -COPY --from=debian-build /opt/rocm/lib/migraphx.cpython-311-x86_64-linux-gnu.so /opt/rocm-$ROCM/lib/ ####################################################################### FROM deps-prelim AS rocm-prelim-hsa-override0 -\ - ENV HSA_ENABLE_SDMA=0 +ENV HSA_ENABLE_SDMA=0 +ENV MIGRAPHX_ENABLE_NHWC=1 COPY --from=rocm-dist / / diff --git a/docker/rocm/migraphx/CMakeLists.txt b/docker/rocm/migraphx/CMakeLists.txt deleted file mode 100644 index 271dd094b..000000000 --- a/docker/rocm/migraphx/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ - -cmake_minimum_required(VERSION 3.1) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -if(NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE Release) -endif() - -SET(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE) - -project(migraphx_py) - -include_directories(/opt/rocm/include) - -find_package(pybind11 REQUIRED) -pybind11_add_module(migraphx migraphx_py.cpp) - -target_link_libraries(migraphx PRIVATE /opt/rocm/lib/libmigraphx.so /opt/rocm/lib/libmigraphx_tf.so /opt/rocm/lib/libmigraphx_onnx.so) - -install(TARGETS migraphx - COMPONENT python - LIBRARY DESTINATION /opt/rocm/lib -) diff --git a/docker/rocm/migraphx/migraphx_py.cpp b/docker/rocm/migraphx/migraphx_py.cpp deleted file mode 100644 index 894c9d186..000000000 --- a/docker/rocm/migraphx/migraphx_py.cpp +++ /dev/null @@ -1,582 +0,0 @@ -/* - * The MIT License (MIT) - * - * Copyright (c) 2015-2022 Advanced Micro Devices, Inc. All rights reserved. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef HAVE_GPU -#include -#endif - -using half = half_float::half; -namespace py = pybind11; - -#ifdef __clang__ -#define MIGRAPHX_PUSH_UNUSED_WARNING \ - _Pragma("clang diagnostic push") \ - _Pragma("clang diagnostic ignored \"-Wused-but-marked-unused\"") -#define MIGRAPHX_POP_WARNING _Pragma("clang diagnostic pop") -#else -#define MIGRAPHX_PUSH_UNUSED_WARNING -#define MIGRAPHX_POP_WARNING -#endif -#define MIGRAPHX_PYBIND11_MODULE(...) \ - MIGRAPHX_PUSH_UNUSED_WARNING \ - PYBIND11_MODULE(__VA_ARGS__) \ - MIGRAPHX_POP_WARNING - -#define MIGRAPHX_PYTHON_GENERATE_SHAPE_ENUM(x, t) .value(#x, migraphx::shape::type_t::x) -namespace migraphx { - -migraphx::value to_value(py::kwargs kwargs); -migraphx::value to_value(py::list lst); - -template -void visit_py(T x, F f) -{ - if(py::isinstance(x)) - { - f(to_value(x.template cast())); - } - else if(py::isinstance(x)) - { - f(to_value(x.template cast())); - } - else if(py::isinstance(x)) - { - f(x.template cast()); - } - else if(py::isinstance(x) or py::hasattr(x, "__index__")) - { - f(x.template cast()); - } - else if(py::isinstance(x)) - { - f(x.template cast()); - } - else if(py::isinstance(x)) - { - f(x.template cast()); - } - else if(py::isinstance(x)) - { - f(migraphx::to_value(x.template cast())); - } - else - { - MIGRAPHX_THROW("VISIT_PY: Unsupported data type!"); - } -} - -migraphx::value to_value(py::list lst) -{ - migraphx::value v = migraphx::value::array{}; - for(auto val : lst) - { - visit_py(val, [&](auto py_val) { v.push_back(py_val); }); - } - - return v; -} - -migraphx::value to_value(py::kwargs kwargs) -{ - migraphx::value v = migraphx::value::object{}; - - for(auto arg : kwargs) - { - auto&& key = py::str(arg.first); - auto&& val = arg.second; - visit_py(val, [&](auto py_val) { v[key] = py_val; }); - } - return v; -} -} // namespace migraphx - -namespace pybind11 { -namespace detail { - -template <> -struct npy_format_descriptor -{ - static std::string format() - { - // following: https://docs.python.org/3/library/struct.html#format-characters - return "e"; - } - static constexpr auto name() { return _("half"); } -}; - -} // namespace detail -} // namespace pybind11 - -template -void visit_type(const migraphx::shape& s, F f) -{ - s.visit_type(f); -} - -template -void visit(const migraphx::raw_data& x, F f) -{ - x.visit(f); -} - -template -void visit_types(F f) -{ - migraphx::shape::visit_types(f); -} - -template -py::buffer_info to_buffer_info(T& x) -{ - migraphx::shape s = x.get_shape(); - assert(s.type() != migraphx::shape::tuple_type); - if(s.dynamic()) - MIGRAPHX_THROW("MIGRAPHX PYTHON: dynamic shape argument passed to to_buffer_info"); - auto strides = s.strides(); - std::transform( - strides.begin(), strides.end(), strides.begin(), [&](auto i) { return i * s.type_size(); }); - py::buffer_info b; - visit_type(s, [&](auto as) { - // migraphx use int8_t data to store bool type, we need to - // explicitly specify the data type as bool for python - if(s.type() == migraphx::shape::bool_type) - { - b = py::buffer_info(x.data(), - as.size(), - py::format_descriptor::format(), - s.ndim(), - s.lens(), - strides); - } - else - { - b = py::buffer_info(x.data(), - as.size(), - py::format_descriptor::format(), - s.ndim(), - s.lens(), - strides); - } - }); - return b; -} - -migraphx::shape to_shape(const py::buffer_info& info) -{ - migraphx::shape::type_t t; - std::size_t n = 0; - visit_types([&](auto as) { - if(info.format == py::format_descriptor::format() or - (info.format == "l" and py::format_descriptor::format() == "q") or - (info.format == "L" and py::format_descriptor::format() == "Q")) - { - t = as.type_enum(); - n = sizeof(as()); - } - else if(info.format == "?" and py::format_descriptor::format() == "b") - { - t = migraphx::shape::bool_type; - n = sizeof(bool); - } - }); - - if(n == 0) - { - MIGRAPHX_THROW("MIGRAPHX PYTHON: Unsupported data type " + info.format); - } - - auto strides = info.strides; - std::transform(strides.begin(), strides.end(), strides.begin(), [&](auto i) -> std::size_t { - return n > 0 ? i / n : 0; - }); - - // scalar support - if(info.shape.empty()) - { - return migraphx::shape{t}; - } - else - { - return migraphx::shape{t, info.shape, strides}; - } -} - -MIGRAPHX_PYBIND11_MODULE(migraphx, m) -{ - py::class_ shape_cls(m, "shape"); - shape_cls - .def(py::init([](py::kwargs kwargs) { - auto v = migraphx::to_value(kwargs); - auto t = migraphx::shape::parse_type(v.get("type", "float")); - if(v.contains("dyn_dims")) - { - auto dyn_dims = - migraphx::from_value>( - v.at("dyn_dims")); - return migraphx::shape(t, dyn_dims); - } - auto lens = v.get("lens", {1}); - if(v.contains("strides")) - return migraphx::shape(t, lens, v.at("strides").to_vector()); - else - return migraphx::shape(t, lens); - })) - .def("type", &migraphx::shape::type) - .def("lens", &migraphx::shape::lens) - .def("strides", &migraphx::shape::strides) - .def("ndim", &migraphx::shape::ndim) - .def("elements", &migraphx::shape::elements) - .def("bytes", &migraphx::shape::bytes) - .def("type_string", &migraphx::shape::type_string) - .def("type_size", &migraphx::shape::type_size) - .def("dyn_dims", &migraphx::shape::dyn_dims) - .def("packed", &migraphx::shape::packed) - .def("transposed", &migraphx::shape::transposed) - .def("broadcasted", &migraphx::shape::broadcasted) - .def("standard", &migraphx::shape::standard) - .def("scalar", &migraphx::shape::scalar) - .def("dynamic", &migraphx::shape::dynamic) - .def("__eq__", std::equal_to{}) - .def("__ne__", std::not_equal_to{}) - .def("__repr__", [](const migraphx::shape& s) { return migraphx::to_string(s); }); - - py::enum_(shape_cls, "type_t") - MIGRAPHX_SHAPE_VISIT_TYPES(MIGRAPHX_PYTHON_GENERATE_SHAPE_ENUM); - - py::class_(shape_cls, "dynamic_dimension") - .def(py::init<>()) - .def(py::init()) - .def(py::init>()) - .def_readwrite("min", &migraphx::shape::dynamic_dimension::min) - .def_readwrite("max", &migraphx::shape::dynamic_dimension::max) - .def_readwrite("optimals", &migraphx::shape::dynamic_dimension::optimals) - .def("is_fixed", &migraphx::shape::dynamic_dimension::is_fixed); - - py::class_(m, "argument", py::buffer_protocol()) - .def_buffer([](migraphx::argument& x) -> py::buffer_info { return to_buffer_info(x); }) - .def(py::init([](py::buffer b) { - py::buffer_info info = b.request(); - return migraphx::argument(to_shape(info), info.ptr); - })) - .def("get_shape", &migraphx::argument::get_shape) - .def("data_ptr", - [](migraphx::argument& x) { return reinterpret_cast(x.data()); }) - .def("tolist", - [](migraphx::argument& x) { - py::list l{x.get_shape().elements()}; - visit(x, [&](auto data) { l = py::cast(data.to_vector()); }); - return l; - }) - .def("__eq__", std::equal_to{}) - .def("__ne__", std::not_equal_to{}) - .def("__repr__", [](const migraphx::argument& x) { return migraphx::to_string(x); }); - - py::class_(m, "target"); - - py::class_(m, "instruction_ref") - .def("shape", [](migraphx::instruction_ref i) { return i->get_shape(); }) - .def("op", [](migraphx::instruction_ref i) { return i->get_operator(); }); - - py::class_>(m, "module") - .def("print", [](const migraphx::module& mm) { std::cout << mm << std::endl; }) - .def( - "add_instruction", - [](migraphx::module& mm, - const migraphx::operation& op, - std::vector& args, - std::vector& mod_args) { - return mm.add_instruction(op, args, mod_args); - }, - py::arg("op"), - py::arg("args"), - py::arg("mod_args") = std::vector{}) - .def( - "add_literal", - [](migraphx::module& mm, py::buffer data) { - py::buffer_info info = data.request(); - auto literal_shape = to_shape(info); - return mm.add_literal(literal_shape, reinterpret_cast(info.ptr)); - }, - py::arg("data")) - .def( - "add_parameter", - [](migraphx::module& mm, const std::string& name, const migraphx::shape shape) { - return mm.add_parameter(name, shape); - }, - py::arg("name"), - py::arg("shape")) - .def( - "add_return", - [](migraphx::module& mm, std::vector& args) { - return mm.add_return(args); - }, - py::arg("args")) - .def("__repr__", [](const migraphx::module& mm) { return migraphx::to_string(mm); }); - - py::class_(m, "program") - .def(py::init([]() { return migraphx::program(); })) - .def("get_parameter_names", &migraphx::program::get_parameter_names) - .def("get_parameter_shapes", &migraphx::program::get_parameter_shapes) - .def("get_output_shapes", &migraphx::program::get_output_shapes) - .def("is_compiled", &migraphx::program::is_compiled) - .def( - "compile", - [](migraphx::program& p, - const migraphx::target& t, - bool offload_copy, - bool fast_math, - bool exhaustive_tune) { - migraphx::compile_options options; - options.offload_copy = offload_copy; - options.fast_math = fast_math; - options.exhaustive_tune = exhaustive_tune; - p.compile(t, options); - }, - py::arg("t"), - py::arg("offload_copy") = true, - py::arg("fast_math") = true, - py::arg("exhaustive_tune") = false) - .def("get_main_module", [](const migraphx::program& p) { return p.get_main_module(); }) - .def( - "create_module", - [](migraphx::program& p, const std::string& name) { return p.create_module(name); }, - py::arg("name")) - .def("run", - [](migraphx::program& p, py::dict params) { - migraphx::parameter_map pm; - for(auto x : params) - { - std::string key = x.first.cast(); - py::buffer b = x.second.cast(); - py::buffer_info info = b.request(); - pm[key] = migraphx::argument(to_shape(info), info.ptr); - } - return p.eval(pm); - }) - .def("run_async", - [](migraphx::program& p, - py::dict params, - std::uintptr_t stream, - std::string stream_name) { - migraphx::parameter_map pm; - for(auto x : params) - { - std::string key = x.first.cast(); - py::buffer b = x.second.cast(); - py::buffer_info info = b.request(); - pm[key] = migraphx::argument(to_shape(info), info.ptr); - } - migraphx::execution_environment exec_env{ - migraphx::any_ptr(reinterpret_cast(stream), stream_name), true}; - return p.eval(pm, exec_env); - }) - .def("sort", &migraphx::program::sort) - .def("print", [](const migraphx::program& p) { std::cout << p << std::endl; }) - .def("__eq__", std::equal_to{}) - .def("__ne__", std::not_equal_to{}) - .def("__repr__", [](const migraphx::program& p) { return migraphx::to_string(p); }); - - py::class_ op(m, "op"); - op.def(py::init([](const std::string& name, py::kwargs kwargs) { - migraphx::value v = migraphx::value::object{}; - if(kwargs) - { - v = migraphx::to_value(kwargs); - } - return migraphx::make_op(name, v); - })) - .def("name", &migraphx::operation::name); - - py::enum_(op, "pooling_mode") - .value("average", migraphx::op::pooling_mode::average) - .value("max", migraphx::op::pooling_mode::max) - .value("lpnorm", migraphx::op::pooling_mode::lpnorm); - - py::enum_(op, "rnn_direction") - .value("forward", migraphx::op::rnn_direction::forward) - .value("reverse", migraphx::op::rnn_direction::reverse) - .value("bidirectional", migraphx::op::rnn_direction::bidirectional); - - m.def( - "argument_from_pointer", - [](const migraphx::shape shape, const int64_t address) { - return migraphx::argument(shape, reinterpret_cast(address)); - }, - py::arg("shape"), - py::arg("address")); - - m.def( - "parse_tf", - [](const std::string& filename, - bool is_nhwc, - unsigned int batch_size, - std::unordered_map> map_input_dims, - std::vector output_names) { - return migraphx::parse_tf( - filename, migraphx::tf_options{is_nhwc, batch_size, map_input_dims, output_names}); - }, - "Parse tf protobuf (default format is nhwc)", - py::arg("filename"), - py::arg("is_nhwc") = true, - py::arg("batch_size") = 1, - py::arg("map_input_dims") = std::unordered_map>(), - py::arg("output_names") = std::vector()); - - m.def( - "parse_onnx", - [](const std::string& filename, - unsigned int default_dim_value, - migraphx::shape::dynamic_dimension default_dyn_dim_value, - std::unordered_map> map_input_dims, - std::unordered_map> - map_dyn_input_dims, - bool skip_unknown_operators, - bool print_program_on_error, - int64_t max_loop_iterations) { - migraphx::onnx_options options; - options.default_dim_value = default_dim_value; - options.default_dyn_dim_value = default_dyn_dim_value; - options.map_input_dims = map_input_dims; - options.map_dyn_input_dims = map_dyn_input_dims; - options.skip_unknown_operators = skip_unknown_operators; - options.print_program_on_error = print_program_on_error; - options.max_loop_iterations = max_loop_iterations; - return migraphx::parse_onnx(filename, options); - }, - "Parse onnx file", - py::arg("filename"), - py::arg("default_dim_value") = 0, - py::arg("default_dyn_dim_value") = migraphx::shape::dynamic_dimension{1, 1}, - py::arg("map_input_dims") = std::unordered_map>(), - py::arg("map_dyn_input_dims") = - std::unordered_map>(), - py::arg("skip_unknown_operators") = false, - py::arg("print_program_on_error") = false, - py::arg("max_loop_iterations") = 10); - - m.def( - "parse_onnx_buffer", - [](const std::string& onnx_buffer, - unsigned int default_dim_value, - migraphx::shape::dynamic_dimension default_dyn_dim_value, - std::unordered_map> map_input_dims, - std::unordered_map> - map_dyn_input_dims, - bool skip_unknown_operators, - bool print_program_on_error) { - migraphx::onnx_options options; - options.default_dim_value = default_dim_value; - options.default_dyn_dim_value = default_dyn_dim_value; - options.map_input_dims = map_input_dims; - options.map_dyn_input_dims = map_dyn_input_dims; - options.skip_unknown_operators = skip_unknown_operators; - options.print_program_on_error = print_program_on_error; - return migraphx::parse_onnx_buffer(onnx_buffer, options); - }, - "Parse onnx file", - py::arg("filename"), - py::arg("default_dim_value") = 0, - py::arg("default_dyn_dim_value") = migraphx::shape::dynamic_dimension{1, 1}, - py::arg("map_input_dims") = std::unordered_map>(), - py::arg("map_dyn_input_dims") = - std::unordered_map>(), - py::arg("skip_unknown_operators") = false, - py::arg("print_program_on_error") = false); - - m.def( - "load", - [](const std::string& name, const std::string& format) { - migraphx::file_options options; - options.format = format; - return migraphx::load(name, options); - }, - "Load MIGraphX program", - py::arg("filename"), - py::arg("format") = "msgpack"); - - m.def( - "save", - [](const migraphx::program& p, const std::string& name, const std::string& format) { - migraphx::file_options options; - options.format = format; - return migraphx::save(p, name, options); - }, - "Save MIGraphX program", - py::arg("p"), - py::arg("filename"), - py::arg("format") = "msgpack"); - - m.def("get_target", &migraphx::make_target); - m.def("create_argument", [](const migraphx::shape& s, const std::vector& values) { - if(values.size() != s.elements()) - MIGRAPHX_THROW("Values and shape elements do not match"); - migraphx::argument a{s}; - a.fill(values.begin(), values.end()); - return a; - }); - m.def("generate_argument", &migraphx::generate_argument, py::arg("s"), py::arg("seed") = 0); - m.def("fill_argument", &migraphx::fill_argument, py::arg("s"), py::arg("value")); - m.def("quantize_fp16", - &migraphx::quantize_fp16, - py::arg("prog"), - py::arg("ins_names") = std::vector{"all"}); - m.def("quantize_int8", - &migraphx::quantize_int8, - py::arg("prog"), - py::arg("t"), - py::arg("calibration") = std::vector{}, - py::arg("ins_names") = std::vector{"dot", "convolution"}); - -#ifdef HAVE_GPU - m.def("allocate_gpu", &migraphx::gpu::allocate_gpu, py::arg("s"), py::arg("host") = false); - m.def("to_gpu", &migraphx::gpu::to_gpu, py::arg("arg"), py::arg("host") = false); - m.def("from_gpu", &migraphx::gpu::from_gpu); - m.def("gpu_sync", [] { migraphx::gpu::gpu_sync(); }); -#endif - -#ifdef VERSION_INFO - m.attr("__version__") = VERSION_INFO; -#else - m.attr("__version__") = "dev"; -#endif -} diff --git a/docker/rocm/requirements-wheels-rocm.txt b/docker/rocm/requirements-wheels-rocm.txt index 89d0e6096..85450768e 100644 --- a/docker/rocm/requirements-wheels-rocm.txt +++ b/docker/rocm/requirements-wheels-rocm.txt @@ -1 +1 @@ -onnxruntime-rocm @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v1.0.0/onnxruntime_rocm-1.17.3-cp39-cp39-linux_x86_64.whl \ No newline at end of file +onnxruntime-rocm @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v6.3.3/onnxruntime_rocm-1.20.1-cp311-cp311-linux_x86_64.whl \ No newline at end of file diff --git a/docker/rocm/rocm-pin-600 b/docker/rocm/rocm-pin-600 deleted file mode 100644 index 88348a5c1..000000000 --- a/docker/rocm/rocm-pin-600 +++ /dev/null @@ -1,3 +0,0 @@ -Package: * -Pin: release o=repo.radeon.com -Pin-Priority: 600 diff --git a/docker/rocm/rocm.hcl b/docker/rocm/rocm.hcl index 33a2d2323..6a84b350d 100644 --- a/docker/rocm/rocm.hcl +++ b/docker/rocm/rocm.hcl @@ -2,7 +2,7 @@ variable "AMDGPU" { default = "gfx900" } variable "ROCM" { - default = "5.7.3" + default = "6.3.3" } variable "HSA_OVERRIDE_GFX_VERSION" { default = "" @@ -10,6 +10,13 @@ variable "HSA_OVERRIDE_GFX_VERSION" { variable "HSA_OVERRIDE" { default = "1" } + +target wget { + dockerfile = "docker/main/Dockerfile" + platforms = ["linux/amd64"] + target = "wget" +} + target deps { dockerfile = "docker/main/Dockerfile" platforms = ["linux/amd64"] @@ -26,6 +33,7 @@ target rocm { dockerfile = "docker/rocm/Dockerfile" contexts = { deps = "target:deps", + wget = "target:wget", rootfs = "target:rootfs" } platforms = ["linux/amd64"] diff --git a/docker/rocm/rocm.list b/docker/rocm/rocm.list deleted file mode 100644 index 0915b4094..000000000 --- a/docker/rocm/rocm.list +++ /dev/null @@ -1 +0,0 @@ -deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/5.7.3 focal main diff --git a/frigate/detectors/plugins/onnx.py b/frigate/detectors/plugins/onnx.py index 13a948de9..d94b4660f 100644 --- a/frigate/detectors/plugins/onnx.py +++ b/frigate/detectors/plugins/onnx.py @@ -99,5 +99,5 @@ class ONNXDetector(DetectionApi): return post_process_yolov9(predictions, self.w, self.h) else: raise Exception( - f"{self.onnx_model_type} is currently not supported for rocm. See the docs for more info on supported models." + f"{self.onnx_model_type} is currently not supported for onnx. See the docs for more info on supported models." ) From 71e6e04d778538e33424c0dac7e5ca08f120b1f0 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 3 Mar 2025 07:16:14 -0700 Subject: [PATCH 14/82] Remove rocm detector (#16913) * Remove rocm detector plugin * Update docs to recommend using onnx for rocm * Formatting --- docs/docs/configuration/object_detectors.md | 30 +--- docs/docs/plus/index.md | 6 +- frigate/detectors/plugins/rocm.py | 170 -------------------- frigate/object_detection.py | 9 +- 4 files changed, 9 insertions(+), 206 deletions(-) delete mode 100644 frigate/detectors/plugins/rocm.py diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 531ef5108..6834f8014 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -49,7 +49,7 @@ This does not affect using hardware for accelerating other tasks such as [semant # Officially Supported Detectors -Frigate provides the following builtin detector types: `cpu`, `edgetpu`, `hailo8l`, `onnx`, `openvino`, `rknn`, `rocm`, and `tensorrt`. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras. +Frigate provides the following builtin detector types: `cpu`, `edgetpu`, `hailo8l`, `onnx`, `openvino`, `rknn`, and `tensorrt`. By default, Frigate will use a single CPU detector. Other detectors may require additional configuration as described below. When using multiple detectors they will run in dedicated processes, but pull from a common queue of detection requests from across all cameras. ## Edge TPU Detector @@ -367,7 +367,7 @@ model: ### Setup -The `rocm` detector supports running YOLO-NAS models on AMD GPUs. Use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`. +Support for AMD GPUs is provided using the [ONNX detector](#ONNX). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`. ### Docker settings for GPU access @@ -446,29 +446,9 @@ $ docker exec -it frigate /bin/bash -c '(unset HSA_OVERRIDE_GFX_VERSION && /opt/ ### Supported Models -There is no default model provided, the following formats are supported: - -#### YOLO-NAS - -[YOLO-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) models are supported, but not included by default. See [the models section](#downloading-yolo-nas-model) for more information on downloading the YOLO-NAS model for use in Frigate. - -After placing the downloaded onnx model in your config folder, you can use the following configuration: - -```yaml -detectors: - rocm: - type: rocm - -model: - model_type: yolonas - width: 320 # <--- should match whatever was set in notebook - height: 320 # <--- should match whatever was set in notebook - input_pixel_format: bgr - path: /config/yolo_nas_s.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. +See [ONNX supported models](#supported-models) for supported models, there are some caveats: +- D-FINE models are not supported +- YOLO-NAS models are known to not run well on integrated GPUs ## ONNX diff --git a/docs/docs/plus/index.md b/docs/docs/plus/index.md index 37798badb..589adca72 100644 --- a/docs/docs/plus/index.md +++ b/docs/docs/plus/index.md @@ -28,11 +28,11 @@ Not all model types are supported by all detectors, so it's important to choose ## Supported detector types -Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), ONNX (`onnx`), and ROCm (`rocm`) detectors. +Currently, Frigate+ models support CPU (`cpu`), Google Coral (`edgetpu`), OpenVino (`openvino`), and ONNX (`onnx`) detectors. :::warning -Using Frigate+ models with `onnx` and `rocm` is only available with Frigate 0.15 and later. +Using Frigate+ models with `onnx` is only available with Frigate 0.15 and later. ::: @@ -42,7 +42,7 @@ Using Frigate+ models with `onnx` and `rocm` is only available with Frigate 0.15 | [Coral (all form factors)](/configuration/object_detectors.md#edge-tpu-detector) | `edgetpu` | `mobiledet` | | [Intel](/configuration/object_detectors.md#openvino-detector) | `openvino` | `yolonas` | | [NVidia GPU](https://deploy-preview-13787--frigate-docs.netlify.app/configuration/object_detectors#onnx)\* | `onnx` | `yolonas` | -| [AMD ROCm GPU](https://deploy-preview-13787--frigate-docs.netlify.app/configuration/object_detectors#amdrocm-gpu-detector)\* | `rocm` | `yolonas` | +| [AMD ROCm GPU](https://deploy-preview-13787--frigate-docs.netlify.app/configuration/object_detectors#amdrocm-gpu-detector)\* | `onnx` | `yolonas` | _\* Requires Frigate 0.15_ diff --git a/frigate/detectors/plugins/rocm.py b/frigate/detectors/plugins/rocm.py deleted file mode 100644 index 7c87edb50..000000000 --- a/frigate/detectors/plugins/rocm.py +++ /dev/null @@ -1,170 +0,0 @@ -import ctypes -import logging -import os -import subprocess -import sys - -import cv2 -import numpy as np -from pydantic import Field -from typing_extensions import Literal - -from frigate.const import MODEL_CACHE_DIR -from frigate.detectors.detection_api import DetectionApi -from frigate.detectors.detector_config import ( - BaseDetectorConfig, - ModelTypeEnum, - PixelFormatEnum, -) - -logger = logging.getLogger(__name__) - -DETECTOR_KEY = "rocm" - - -def detect_gfx_version(): - return subprocess.getoutput( - "unset HSA_OVERRIDE_GFX_VERSION && /opt/rocm/bin/rocminfo | grep gfx |head -1|awk '{print $2}'" - ) - - -def auto_override_gfx_version(): - # If environment variable already in place, do not override - gfx_version = detect_gfx_version() - old_override = os.getenv("HSA_OVERRIDE_GFX_VERSION") - if old_override not in (None, ""): - logger.warning( - f"AMD/ROCm: detected {gfx_version} but HSA_OVERRIDE_GFX_VERSION already present ({old_override}), not overriding!" - ) - return old_override - mapping = { - "gfx90c": "9.0.0", - "gfx1031": "10.3.0", - "gfx1103": "11.0.0", - } - override = mapping.get(gfx_version) - if override is not None: - logger.warning( - f"AMD/ROCm: detected {gfx_version}, overriding HSA_OVERRIDE_GFX_VERSION={override}" - ) - os.putenv("HSA_OVERRIDE_GFX_VERSION", override) - return override - return "" - - -class ROCmDetectorConfig(BaseDetectorConfig): - type: Literal[DETECTOR_KEY] - conserve_cpu: bool = Field( - default=True, - title="Conserve CPU at the expense of latency (and reduced max throughput)", - ) - auto_override_gfx: bool = Field( - default=True, title="Automatically detect and override gfx version" - ) - - -class ROCmDetector(DetectionApi): - type_key = DETECTOR_KEY - - def __init__(self, detector_config: ROCmDetectorConfig): - if detector_config.auto_override_gfx: - auto_override_gfx_version() - - try: - sys.path.append("/opt/rocm/lib") - import migraphx - - logger.info("AMD/ROCm: loaded migraphx module") - except ModuleNotFoundError: - logger.error("AMD/ROCm: module loading failed, missing ROCm environment?") - raise - - if detector_config.conserve_cpu: - logger.info("AMD/ROCm: switching HIP to blocking mode to conserve CPU") - ctypes.CDLL("/opt/rocm/lib/libamdhip64.so").hipSetDeviceFlags(4) - - self.h = detector_config.model.height - self.w = detector_config.model.width - self.rocm_model_type = detector_config.model.model_type - self.rocm_model_px = detector_config.model.input_pixel_format - path = detector_config.model.path - - mxr_path = os.path.splitext(path)[0] + ".mxr" - if path.endswith(".mxr"): - logger.info(f"AMD/ROCm: loading parsed model from {mxr_path}") - self.model = migraphx.load(mxr_path) - elif os.path.exists(mxr_path): - logger.info(f"AMD/ROCm: loading parsed model from {mxr_path}") - self.model = migraphx.load(mxr_path) - else: - logger.info(f"AMD/ROCm: loading model from {path}") - - if ( - path.endswith(".tf") - or path.endswith(".tf2") - or path.endswith(".tflite") - ): - # untested - self.model = migraphx.parse_tf(path) - else: - self.model = migraphx.parse_onnx(path) - - logger.info("AMD/ROCm: compiling the model") - - self.model.compile( - migraphx.get_target("gpu"), offload_copy=True, fast_math=True - ) - - logger.info(f"AMD/ROCm: saving parsed model into {mxr_path}") - - os.makedirs(os.path.join(MODEL_CACHE_DIR, "rocm"), exist_ok=True) - migraphx.save(self.model, mxr_path) - - logger.info("AMD/ROCm: model loaded") - - def detect_raw(self, tensor_input): - model_input_name = self.model.get_parameter_names()[0] - model_input_shape = tuple( - self.model.get_parameter_shapes()[model_input_name].lens() - ) - - tensor_input = cv2.dnn.blobFromImage( - tensor_input[0], - 1.0, - (model_input_shape[3], model_input_shape[2]), - None, - swapRB=self.rocm_model_px == PixelFormatEnum.bgr, - ).astype(np.uint8) - - detector_result = self.model.run({model_input_name: tensor_input})[0] - addr = ctypes.cast(detector_result.data_ptr(), ctypes.POINTER(ctypes.c_float)) - - tensor_output = np.ctypeslib.as_array( - addr, shape=detector_result.get_shape().lens() - ) - - if self.rocm_model_type == ModelTypeEnum.yolonas: - predictions = tensor_output - - detections = np.zeros((20, 6), np.float32) - - for i, prediction in enumerate(predictions): - if i == 20: - break - (_, x_min, y_min, x_max, y_max, confidence, class_id) = prediction - # when running in GPU mode, empty predictions in the output have class_id of -1 - if class_id < 0: - break - detections[i] = [ - class_id, - confidence, - y_min / self.h, - x_min / self.w, - y_max / self.h, - x_max / self.w, - ] - return detections - else: - raise Exception( - f"{self.rocm_model_type} is currently not supported for rocm. See the docs for more info on supported models." - ) diff --git a/frigate/object_detection.py b/frigate/object_detection.py index 022e565f0..8e88ae578 100644 --- a/frigate/object_detection.py +++ b/frigate/object_detection.py @@ -17,7 +17,6 @@ from frigate.detectors.detector_config import ( InputDTypeEnum, InputTensorEnum, ) -from frigate.detectors.plugins.rocm import DETECTOR_KEY as ROCM_DETECTOR_KEY from frigate.util.builtin import EventsPerSecond, load_labels from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory from frigate.util.services import listen @@ -52,13 +51,7 @@ class LocalObjectDetector(ObjectDetector): self.labels = load_labels(labels) if detector_config: - if detector_config.type == ROCM_DETECTOR_KEY: - # ROCm requires NHWC as input - self.input_transform = None - else: - self.input_transform = tensor_transform( - detector_config.model.input_tensor - ) + self.input_transform = tensor_transform(detector_config.model.input_tensor) self.dtype = detector_config.model.input_dtype else: From 531042467ab85180bf20b3f265ae9256f157f5bf Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 3 Mar 2025 09:30:52 -0600 Subject: [PATCH 15/82] Dynamically enable/disable cameras (#16894) * config options * metrics * stop and restart ffmpeg processes * dispatcher * frontend websocket * buttons for testing * don't recreate log pipe * add/remove cam from birdseye when enabling/disabling * end all objects and send empty camera activity * enable/disable switch in ui * disable buttons when camera is disabled * use enabled_in_config for some frontend checks * tweaks * handle settings pane with disabled cameras * frontend tweaks * change to debug log * mqtt docs * tweak * ensure all ffmpeg processes are initially started * clean up * use zmq * remove camera metrics * remove camera metrics * tweaks * frontend tweaks --- docs/docs/integrations/mqtt.md | 8 + frigate/camera/activity_manager.py | 2 +- frigate/comms/dispatcher.py | 23 ++ frigate/config/camera/camera.py | 3 + frigate/config/config.py | 1 + frigate/object_processing.py | 62 +++++ frigate/output/birdseye.py | 227 ++++++++++-------- frigate/output/output.py | 20 ++ frigate/video.py | 142 +++++++++-- web/src/api/ws.tsx | 13 + web/src/components/camera/CameraImage.tsx | 8 +- .../components/camera/ResizingCameraImage.tsx | 4 +- .../dynamic/CameraFeatureToggle.tsx | 23 +- web/src/components/menu/LiveContextMenu.tsx | 114 +++++++-- web/src/components/player/LivePlayer.tsx | 90 +++++-- web/src/components/settings/ZoneEditPane.tsx | 2 +- web/src/hooks/use-camera-activity.ts | 18 +- web/src/pages/Live.tsx | 6 +- web/src/pages/Settings.tsx | 84 +++++-- web/src/types/frigateConfig.ts | 1 + web/src/types/ws.ts | 1 + web/src/views/live/LiveCameraView.tsx | 32 ++- web/src/views/settings/CameraSettingsView.tsx | 29 ++- .../settings/NotificationsSettingsView.tsx | 2 +- 24 files changed, 713 insertions(+), 202 deletions(-) diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 4eaf61919..fc8888e40 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -222,6 +222,14 @@ Publishes the rms value for audio detected on this camera. **NOTE:** Requires audio detection to be enabled +### `frigate//enabled/set` + +Topic to turn Frigate's processing of a camera on and off. Expected values are `ON` and `OFF`. + +### `frigate//enabled/state` + +Topic with current state of processing for a camera. Published values are `ON` and `OFF`. + ### `frigate//detect/set` Topic to turn object detection for a camera on and off. Expected values are `ON` and `OFF`. diff --git a/frigate/camera/activity_manager.py b/frigate/camera/activity_manager.py index a6e40f4ca..7f6354641 100644 --- a/frigate/camera/activity_manager.py +++ b/frigate/camera/activity_manager.py @@ -20,7 +20,7 @@ class CameraActivityManager: self.all_zone_labels: dict[str, set[str]] = {} for camera_config in config.cameras.values(): - if not camera_config.enabled: + if not camera_config.enabled_in_config: continue self.last_camera_activity[camera_config.name] = {} diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 61530d086..586b70cbb 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -55,6 +55,7 @@ class Dispatcher: self._camera_settings_handlers: dict[str, Callable] = { "audio": self._on_audio_command, "detect": self._on_detect_command, + "enabled": self._on_enabled_command, "improve_contrast": self._on_motion_improve_contrast_command, "ptz_autotracker": self._on_ptz_autotracker_command, "motion": self._on_motion_command, @@ -167,6 +168,7 @@ class Dispatcher: for camera in camera_status.keys(): camera_status[camera]["config"] = { "detect": self.config.cameras[camera].detect.enabled, + "enabled": self.config.cameras[camera].enabled, "snapshots": self.config.cameras[camera].snapshots.enabled, "record": self.config.cameras[camera].record.enabled, "audio": self.config.cameras[camera].audio.enabled, @@ -278,6 +280,27 @@ class Dispatcher: self.config_updater.publish(f"config/detect/{camera_name}", detect_settings) self.publish(f"{camera_name}/detect/state", payload, retain=True) + def _on_enabled_command(self, camera_name: str, payload: str) -> None: + """Callback for camera topic.""" + camera_settings = self.config.cameras[camera_name] + + if payload == "ON": + if not self.config.cameras[camera_name].enabled_in_config: + logger.error( + "Camera must be enabled in the config to be turned on via MQTT." + ) + return + if not camera_settings.enabled: + logger.info(f"Turning on camera {camera_name}") + camera_settings.enabled = True + elif payload == "OFF": + if camera_settings.enabled: + logger.info(f"Turning off camera {camera_name}") + camera_settings.enabled = False + + self.config_updater.publish(f"config/enabled/{camera_name}", camera_settings) + self.publish(f"{camera_name}/enabled/state", payload, retain=True) + def _on_motion_command(self, camera_name: str, payload: str) -> None: """Callback for motion topic.""" detect_settings = self.config.cameras[camera_name].detect diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index 50f61f33c..2d928661e 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -102,6 +102,9 @@ class CameraConfig(FrigateBaseModel): zones: dict[str, ZoneConfig] = Field( default_factory=dict, title="Zone configuration." ) + enabled_in_config: Optional[bool] = Field( + default=None, title="Keep track of original state of camera." + ) _ffmpeg_cmds: list[dict[str, list[str]]] = PrivateAttr() diff --git a/frigate/config/config.py b/frigate/config/config.py index d2ca9a6f5..633aef803 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -516,6 +516,7 @@ class FrigateConfig(FrigateBaseModel): camera_config.detect.stationary.interval = stationary_threshold # set config pre-value + camera_config.enabled_in_config = camera_config.enabled camera_config.audio.enabled_in_config = camera_config.audio.enabled camera_config.record.enabled_in_config = camera_config.record.enabled camera_config.notifications.enabled_in_config = ( diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 137883b2b..783c2b2d0 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -10,6 +10,7 @@ from typing import Callable, Optional import cv2 import numpy as np +from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.dispatcher import Dispatcher from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher @@ -61,6 +62,7 @@ class CameraState: self.previous_frame_id = None self.callbacks = defaultdict(list) self.ptz_autotracker_thread = ptz_autotracker_thread + self.prev_enabled = self.camera_config.enabled def get_current_frame(self, draw_options={}): with self.current_frame_lock: @@ -310,6 +312,7 @@ class CameraState: # TODO: can i switch to looking this up and only changing when an event ends? # maintain best objects camera_activity: dict[str, list[any]] = { + "enabled": True, "motion": len(motion_boxes) > 0, "objects": [], } @@ -437,6 +440,11 @@ class TrackedObjectProcessor(threading.Thread): self.last_motion_detected: dict[str, float] = {} self.ptz_autotracker_thread = ptz_autotracker_thread + self.enabled_subscribers = { + camera: ConfigSubscriber(f"config/enabled/{camera}", True) + for camera in config.cameras.keys() + } + self.requestor = InterProcessRequestor() self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) self.event_sender = EventUpdatePublisher() @@ -679,8 +687,55 @@ class TrackedObjectProcessor(threading.Thread): """Returns the latest frame time for a given camera.""" return self.camera_states[camera].current_frame_time + def force_end_all_events(self, camera: str, camera_state: CameraState): + """Ends all active events on camera when disabling.""" + last_frame_name = camera_state.previous_frame_id + for obj_id, obj in list(camera_state.tracked_objects.items()): + if "end_time" not in obj.obj_data: + logger.debug(f"Camera {camera} disabled, ending active event {obj_id}") + obj.obj_data["end_time"] = datetime.datetime.now().timestamp() + # end callbacks + for callback in camera_state.callbacks["end"]: + callback(camera, obj, last_frame_name) + + # camera activity callbacks + for callback in camera_state.callbacks["camera_activity"]: + callback( + camera, + {"enabled": False, "motion": 0, "objects": []}, + ) + + def _get_enabled_state(self, camera: str) -> bool: + _, config_data = self.enabled_subscribers[camera].check_for_update() + if config_data: + enabled = config_data.enabled + if self.camera_states[camera].prev_enabled is None: + self.camera_states[camera].prev_enabled = enabled + return enabled + return ( + self.camera_states[camera].prev_enabled + if self.camera_states[camera].prev_enabled is not None + else self.config.cameras[camera].enabled + ) + def run(self): while not self.stop_event.is_set(): + for camera, config in self.config.cameras.items(): + if not config.enabled_in_config: + continue + + current_enabled = self._get_enabled_state(camera) + camera_state = self.camera_states[camera] + + if camera_state.prev_enabled and not current_enabled: + logger.debug(f"Not processing objects for disabled camera {camera}") + self.force_end_all_events(camera, camera_state) + + camera_state.prev_enabled = current_enabled + + if not current_enabled: + continue + try: ( camera, @@ -693,6 +748,10 @@ class TrackedObjectProcessor(threading.Thread): except queue.Empty: continue + if not self._get_enabled_state(camera): + logger.debug(f"Camera {camera} disabled, skipping update") + continue + camera_state = self.camera_states[camera] camera_state.update( @@ -735,4 +794,7 @@ class TrackedObjectProcessor(threading.Thread): self.detection_publisher.stop() self.event_sender.stop() self.event_end_subscriber.stop() + for subscriber in self.enabled_subscribers.values(): + subscriber.stop() + logger.info("Exiting object processor...") diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 8331eb64a..3d036e9d5 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -10,6 +10,7 @@ import queue import subprocess as sp import threading import traceback +from typing import Optional import cv2 import numpy as np @@ -280,6 +281,12 @@ class BirdsEyeFrameManager: self.stop_event = stop_event self.inactivity_threshold = config.birdseye.inactivity_threshold + self.enabled_subscribers = { + cam: ConfigSubscriber(f"config/enabled/{cam}", True) + for cam in config.cameras.keys() + if config.cameras[cam].enabled_in_config + } + if config.birdseye.layout.max_cameras: self.last_refresh_time = 0 @@ -380,8 +387,18 @@ class BirdsEyeFrameManager: if mode == BirdseyeModeEnum.objects and object_box_count > 0: return True - def update_frame(self, frame: np.ndarray): - """Update to a new frame for birdseye.""" + def _get_enabled_state(self, camera: str) -> bool: + """Fetch the latest enabled state for a camera from ZMQ.""" + _, config_data = self.enabled_subscribers[camera].check_for_update() + if config_data: + return config_data.enabled + return self.config.cameras[camera].enabled + + def update_frame(self, frame: Optional[np.ndarray] = None) -> bool: + """ + Update birdseye, optionally with a new frame. + When no frame is passed, check the layout and update for any disabled cameras. + """ # determine how many cameras are tracking objects within the last inactivity_threshold seconds active_cameras: set[str] = set( @@ -389,11 +406,14 @@ class BirdsEyeFrameManager: cam for cam, cam_data in self.cameras.items() if self.config.cameras[cam].birdseye.enabled + and self.config.cameras[cam].enabled_in_config + and self._get_enabled_state(cam) and cam_data["last_active_frame"] > 0 and cam_data["current_frame_time"] - cam_data["last_active_frame"] < self.inactivity_threshold ] ) + logger.debug(f"Active cameras: {active_cameras}") max_cameras = self.config.birdseye.layout.max_cameras max_camera_refresh = False @@ -411,118 +431,125 @@ class BirdsEyeFrameManager: - self.cameras[active_camera]["last_active_frame"] ), ) - active_cameras = limited_active_cameras[ - : self.config.birdseye.layout.max_cameras - ] + active_cameras = limited_active_cameras[:max_cameras] max_camera_refresh = True self.last_refresh_time = now - # if there are no active cameras + # Track if the frame changes + frame_changed = False + + # If no active cameras and layout is already empty, no update needed if len(active_cameras) == 0: # if the layout is already cleared if len(self.camera_layout) == 0: return False # if the layout needs to be cleared - else: - self.camera_layout = [] - self.active_cameras = set() - self.clear_frame() - return True - - # check if we need to reset the layout because there is a different number of cameras - if len(self.active_cameras) - len(active_cameras) == 0: - if len(self.active_cameras) == 1 and self.active_cameras != active_cameras: - reset_layout = True - elif max_camera_refresh: - reset_layout = True - else: - reset_layout = False - else: - reset_layout = True - - # reset the layout if it needs to be different - if reset_layout: - logger.debug("Added new cameras, resetting layout...") + self.camera_layout = [] + self.active_cameras = set() self.clear_frame() - self.active_cameras = active_cameras - - # this also converts added_cameras from a set to a list since we need - # to pop elements in order - active_cameras_to_add = sorted( - active_cameras, - # sort cameras by order and by name if the order is the same - key=lambda active_camera: ( - self.config.cameras[active_camera].birdseye.order, - active_camera, - ), - ) - - if len(active_cameras) == 1: - # show single camera as fullscreen - camera = active_cameras_to_add[0] - camera_dims = self.cameras[camera]["dimensions"].copy() - scaled_width = int(self.canvas.height * camera_dims[0] / camera_dims[1]) - - # center camera view in canvas and ensure that it fits - if scaled_width < self.canvas.width: - coefficient = 1 - x_offset = int((self.canvas.width - scaled_width) / 2) + frame_changed = True + else: + # Determine if layout needs resetting + if len(self.active_cameras) - len(active_cameras) == 0: + if ( + len(self.active_cameras) == 1 + and self.active_cameras != active_cameras + ): + reset_layout = True + elif max_camera_refresh: + reset_layout = True else: - coefficient = self.canvas.width / scaled_width - x_offset = int( - (self.canvas.width - (scaled_width * coefficient)) / 2 - ) - - self.camera_layout = [ - [ - ( - camera, - ( - x_offset, - 0, - int(scaled_width * coefficient), - int(self.canvas.height * coefficient), - ), - ) - ] - ] + reset_layout = False else: - # calculate optimal layout - coefficient = self.canvas.get_coefficient(len(active_cameras)) - calculating = True + reset_layout = True - # decrease scaling coefficient until height of all cameras can fit into the birdseye canvas - while calculating: - if self.stop_event.is_set(): - return + if reset_layout: + logger.debug("Resetting Birdseye layout...") + self.clear_frame() + self.active_cameras = active_cameras - layout_candidate = self.calculate_layout( - active_cameras_to_add, - coefficient, + # this also converts added_cameras from a set to a list since we need + # to pop elements in order + active_cameras_to_add = sorted( + active_cameras, + # sort cameras by order and by name if the order is the same + key=lambda active_camera: ( + self.config.cameras[active_camera].birdseye.order, + active_camera, + ), + ) + if len(active_cameras) == 1: + # show single camera as fullscreen + camera = active_cameras_to_add[0] + camera_dims = self.cameras[camera]["dimensions"].copy() + scaled_width = int( + self.canvas.height * camera_dims[0] / camera_dims[1] ) - if not layout_candidate: - if coefficient < 10: - coefficient += 1 - continue - else: - logger.error("Error finding appropriate birdseye layout") + # center camera view in canvas and ensure that it fits + if scaled_width < self.canvas.width: + coefficient = 1 + x_offset = int((self.canvas.width - scaled_width) / 2) + else: + coefficient = self.canvas.width / scaled_width + x_offset = int( + (self.canvas.width - (scaled_width * coefficient)) / 2 + ) + + self.camera_layout = [ + [ + ( + camera, + ( + x_offset, + 0, + int(scaled_width * coefficient), + int(self.canvas.height * coefficient), + ), + ) + ] + ] + else: + # calculate optimal layout + coefficient = self.canvas.get_coefficient(len(active_cameras)) + calculating = True + + # decrease scaling coefficient until height of all cameras can fit into the birdseye canvas + while calculating: + if self.stop_event.is_set(): return - calculating = False - self.canvas.set_coefficient(len(active_cameras), coefficient) + layout_candidate = self.calculate_layout( + active_cameras_to_add, coefficient + ) - self.camera_layout = layout_candidate + if not layout_candidate: + if coefficient < 10: + coefficient += 1 + continue + else: + logger.error( + "Error finding appropriate birdseye layout" + ) + return + calculating = False + self.canvas.set_coefficient(len(active_cameras), coefficient) - for row in self.camera_layout: - for position in row: - self.copy_to_position( - position[1], - position[0], - self.cameras[position[0]]["current_frame"], - ) + self.camera_layout = layout_candidate + frame_changed = True - return True + # Draw the layout + for row in self.camera_layout: + for position in row: + src_frame = self.cameras[position[0]]["current_frame"] + if src_frame is None or src_frame.size == 0: + logger.debug(f"Skipping invalid frame for {position[0]}") + continue + self.copy_to_position(position[1], position[0], src_frame) + if frame is not None: # Frame presence indicates a potential change + frame_changed = True + + return frame_changed def calculate_layout( self, @@ -678,11 +705,8 @@ class BirdsEyeFrameManager: # don't process if birdseye is disabled for this camera camera_config = self.config.cameras[camera].birdseye - if not camera_config.enabled: - return False - # disabling birdseye is a little tricky - if not camera_config.enabled: + if not camera_config.enabled or not self._get_enabled_state(camera): # if we've rendered a frame (we have a value for last_active_frame) # then we need to set it to zero if self.cameras[camera]["last_active_frame"] > 0: @@ -716,6 +740,11 @@ class BirdsEyeFrameManager: return True return False + def stop(self): + """Clean up subscribers when stopping.""" + for subscriber in self.enabled_subscribers.values(): + subscriber.stop() + class Birdseye: def __init__( @@ -743,6 +772,7 @@ class Birdseye: self.birdseye_manager = BirdsEyeFrameManager(config, stop_event) self.config_subscriber = ConfigSubscriber("config/birdseye/") self.frame_manager = SharedMemoryFrameManager() + self.stop_event = stop_event if config.birdseye.restream: self.birdseye_buffer = self.frame_manager.create( @@ -794,5 +824,6 @@ class Birdseye: def stop(self) -> None: self.config_subscriber.stop() + self.birdseye_manager.stop() self.converter.join() self.broadcaster.join() diff --git a/frigate/output/output.py b/frigate/output/output.py index bb2d73511..9beb87250 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -17,6 +17,7 @@ from ws4py.server.wsgirefserver import ( ) from ws4py.server.wsgiutils import WebSocketWSGIApplication +from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum from frigate.comms.ws import WebSocket from frigate.config import FrigateConfig @@ -59,6 +60,12 @@ def output_frames( detection_subscriber = DetectionSubscriber(DetectionTypeEnum.video) + enabled_subscribers = { + camera: ConfigSubscriber(f"config/enabled/{camera}", True) + for camera in config.cameras.keys() + if config.cameras[camera].enabled_in_config + } + jsmpeg_cameras: dict[str, JsmpegCamera] = {} birdseye: Optional[Birdseye] = None preview_recorders: dict[str, PreviewRecorder] = {} @@ -80,6 +87,13 @@ def output_frames( websocket_thread.start() + def get_enabled_state(camera: str) -> bool: + _, config_data = enabled_subscribers[camera].check_for_update() + if config_data: + return config_data.enabled + # default + return config.cameras[camera].enabled + while not stop_event.is_set(): (topic, data) = detection_subscriber.check_for_update(timeout=1) @@ -95,6 +109,9 @@ def output_frames( _, ) = data + if not get_enabled_state(camera): + continue + frame = frame_manager.get(frame_name, config.cameras[camera].frame_shape_yuv) if frame is None: @@ -184,6 +201,9 @@ def output_frames( if birdseye is not None: birdseye.stop() + for subscriber in enabled_subscribers.values(): + subscriber.stop() + websocket_server.manager.close_all() websocket_server.manager.stop() websocket_server.manager.join() diff --git a/frigate/video.py b/frigate/video.py index 233cebb9e..69f6c1bfa 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -108,8 +108,20 @@ def capture_frames( frame_rate.start() skipped_eps = EventsPerSecond() skipped_eps.start() + config_subscriber = ConfigSubscriber(f"config/enabled/{config.name}", True) + + def get_enabled_state(): + """Fetch the latest enabled state from ZMQ.""" + _, config_data = config_subscriber.check_for_update() + if config_data: + return config_data.enabled + return config.enabled + + while not stop_event.is_set(): + if not get_enabled_state(): + logger.debug(f"Stopping capture thread for disabled {config.name}") + break - while True: fps.value = frame_rate.eps() skipped_fps.value = skipped_eps.eps() current_frame.value = datetime.datetime.now().timestamp() @@ -178,26 +190,37 @@ class CameraWatchdog(threading.Thread): self.stop_event = stop_event self.sleeptime = self.config.ffmpeg.retry_interval - def run(self): - self.start_ffmpeg_detect() + self.config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True) + self.was_enabled = self.config.enabled - for c in self.config.ffmpeg_cmds: - if "detect" in c["roles"]: - continue - logpipe = LogPipe( - f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}" - ) - self.ffmpeg_other_processes.append( - { - "cmd": c["cmd"], - "roles": c["roles"], - "logpipe": logpipe, - "process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe), - } - ) + def _update_enabled_state(self) -> bool: + """Fetch the latest config and update enabled state.""" + _, config_data = self.config_subscriber.check_for_update() + if config_data: + enabled = config_data.enabled + return enabled + return self.was_enabled if self.was_enabled is not None else self.config.enabled + + def run(self): + if self._update_enabled_state(): + self.start_all_ffmpeg() time.sleep(self.sleeptime) while not self.stop_event.wait(self.sleeptime): + enabled = self._update_enabled_state() + if enabled != self.was_enabled: + if enabled: + self.logger.debug(f"Enabling camera {self.camera_name}") + self.start_all_ffmpeg() + else: + self.logger.debug(f"Disabling camera {self.camera_name}") + self.stop_all_ffmpeg() + self.was_enabled = enabled + continue + + if not enabled: + continue + now = datetime.datetime.now().timestamp() if not self.capture_thread.is_alive(): @@ -279,11 +302,9 @@ class CameraWatchdog(threading.Thread): p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"] ) - stop_ffmpeg(self.ffmpeg_detect_process, self.logger) - for p in self.ffmpeg_other_processes: - stop_ffmpeg(p["process"], self.logger) - p["logpipe"].close() + self.stop_all_ffmpeg() self.logpipe.close() + self.config_subscriber.stop() def start_ffmpeg_detect(self): ffmpeg_cmd = [ @@ -306,6 +327,43 @@ class CameraWatchdog(threading.Thread): ) self.capture_thread.start() + def start_all_ffmpeg(self): + """Start all ffmpeg processes (detection and others).""" + logger.debug(f"Starting all ffmpeg processes for {self.camera_name}") + self.start_ffmpeg_detect() + for c in self.config.ffmpeg_cmds: + if "detect" in c["roles"]: + continue + logpipe = LogPipe( + f"ffmpeg.{self.camera_name}.{'_'.join(sorted(c['roles']))}" + ) + self.ffmpeg_other_processes.append( + { + "cmd": c["cmd"], + "roles": c["roles"], + "logpipe": logpipe, + "process": start_or_restart_ffmpeg(c["cmd"], self.logger, logpipe), + } + ) + + def stop_all_ffmpeg(self): + """Stop all ffmpeg processes (detection and others).""" + logger.debug(f"Stopping all ffmpeg processes for {self.camera_name}") + if self.capture_thread is not None and self.capture_thread.is_alive(): + self.capture_thread.join(timeout=5) + if self.capture_thread.is_alive(): + self.logger.warning( + f"Capture thread for {self.camera_name} did not stop gracefully." + ) + if self.ffmpeg_detect_process is not None: + stop_ffmpeg(self.ffmpeg_detect_process, self.logger) + self.ffmpeg_detect_process = None + for p in self.ffmpeg_other_processes[:]: + if p["process"] is not None: + stop_ffmpeg(p["process"], self.logger) + p["logpipe"].close() + self.ffmpeg_other_processes.clear() + def get_latest_segment_datetime(self, latest_segment: datetime.datetime) -> int: """Checks if ffmpeg is still writing recording segments to cache.""" cache_files = sorted( @@ -539,7 +597,8 @@ def process_frames( exit_on_empty: bool = False, ): next_region_update = get_tomorrow_at_time(2) - config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True) + detect_config_subscriber = ConfigSubscriber(f"config/detect/{camera_name}", True) + enabled_config_subscriber = ConfigSubscriber(f"config/enabled/{camera_name}", True) fps_tracker = EventsPerSecond() fps_tracker.start() @@ -549,9 +608,43 @@ def process_frames( region_min_size = get_min_region_size(model_config) + prev_enabled = None + while not stop_event.is_set(): + _, enabled_config = enabled_config_subscriber.check_for_update() + current_enabled = ( + enabled_config.enabled + if enabled_config + else (prev_enabled if prev_enabled is not None else True) + ) + if prev_enabled is None: + prev_enabled = current_enabled + + if prev_enabled and not current_enabled and camera_metrics.frame_queue.empty(): + logger.debug(f"Camera {camera_name} disabled, clearing tracked objects") + + # Clear norfair's dictionaries + object_tracker.tracked_objects.clear() + object_tracker.disappeared.clear() + object_tracker.stationary_box_history.clear() + object_tracker.positions.clear() + object_tracker.track_id_map.clear() + + # Clear internal norfair states + for trackers_by_type in object_tracker.trackers.values(): + for tracker in trackers_by_type.values(): + tracker.tracked_objects = [] + for tracker in object_tracker.default_tracker.values(): + tracker.tracked_objects = [] + + prev_enabled = current_enabled + + if not current_enabled: + time.sleep(0.1) + continue + # check for updated detect config - _, updated_detect_config = config_subscriber.check_for_update() + _, updated_detect_config = detect_config_subscriber.check_for_update() if updated_detect_config: detect_config = updated_detect_config @@ -845,4 +938,5 @@ def process_frames( motion_detector.stop() requestor.stop() - config_subscriber.stop() + detect_config_subscriber.stop() + enabled_config_subscriber.stop() diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 7ca9ae69d..27600993a 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -56,6 +56,7 @@ function useValue(): useValueReturn { const { record, detect, + enabled, snapshots, audio, notifications, @@ -67,6 +68,7 @@ function useValue(): useValueReturn { // @ts-expect-error we know this is correct state["config"]; cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF"; + cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF"; cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF"; cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF"; cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF"; @@ -164,6 +166,17 @@ export function useWs(watchTopic: string, publishTopic: string) { return { value, send }; } +export function useEnabledState(camera: string): { + payload: ToggleableSetting; + send: (payload: ToggleableSetting, retain?: boolean) => void; +} { + const { + value: { payload }, + send, + } = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`); + return { payload: payload as ToggleableSetting, send }; +} + export function useDetectState(camera: string): { payload: ToggleableSetting; send: (payload: ToggleableSetting, retain?: boolean) => void; diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index ba35d643e..fe6586fcc 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -5,6 +5,7 @@ import ActivityIndicator from "../indicators/activity-indicator"; import { useResizeObserver } from "@/hooks/resize-observer"; import { isDesktop } from "react-device-detect"; import { cn } from "@/lib/utils"; +import { useEnabledState } from "@/api/ws"; type CameraImageProps = { className?: string; @@ -26,7 +27,8 @@ export default function CameraImage({ const imgRef = useRef(null); const { name } = config ? config.cameras[camera] : ""; - const enabled = config ? config.cameras[camera].enabled : "True"; + const { payload: enabledState } = useEnabledState(camera); + const enabled = enabledState === "ON" || enabledState === undefined; const [{ width: containerWidth, height: containerHeight }] = useResizeObserver(containerRef); @@ -96,9 +98,7 @@ export default function CameraImage({ loading="lazy" /> ) : ( -
- Camera is disabled in config, no stream or snapshot available! -
+
)} {!imageLoaded && enabled ? (
diff --git a/web/src/components/camera/ResizingCameraImage.tsx b/web/src/components/camera/ResizingCameraImage.tsx index 81545c625..fbb57677b 100644 --- a/web/src/components/camera/ResizingCameraImage.tsx +++ b/web/src/components/camera/ResizingCameraImage.tsx @@ -108,9 +108,7 @@ export default function CameraImage({ width={scaledWidth} /> ) : ( -
- Camera is disabled in config, no stream or snapshot available! -
+
Camera is disabled.
)} {!hasLoaded && enabled ? (
void; + disabled?: boolean; // New prop for disabling }; export default function CameraFeatureToggle({ @@ -35,18 +40,28 @@ export default function CameraFeatureToggle({ Icon, title, onClick, + disabled = false, // Default to false }: CameraFeatureToggleProps) { const content = (
); @@ -54,7 +69,7 @@ export default function CameraFeatureToggle({ if (isDesktop) { return ( - {content} + {content}

{title}

diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 969e647a0..9c775e0ac 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -39,7 +39,11 @@ import { import { cn } from "@/lib/utils"; import { useNavigate } from "react-router-dom"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; -import { useNotifications, useNotificationSuspend } from "@/api/ws"; +import { + useEnabledState, + useNotifications, + useNotificationSuspend, +} from "@/api/ws"; type LiveContextMenuProps = { className?: string; @@ -83,6 +87,11 @@ export default function LiveContextMenu({ }: LiveContextMenuProps) { const [showSettings, setShowSettings] = useState(false); + // camera enabled + + const { payload: enabledState, send: sendEnabled } = useEnabledState(camera); + const isEnabled = enabledState === "ON"; + // streaming settings const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } = @@ -263,7 +272,7 @@ export default function LiveContextMenu({ onClick={handleVolumeIconClick} />
sendEnabled(isEnabled ? "OFF" : "ON")} + > +
+ {isEnabled ? "Disable" : "Enable"} Camera +
+
+ + + +
Mute All Cameras
- +
Unmute All Cameras
- +
{statsState ? "Hide" : "Show"} Stream Stats
- +
navigate(`/settings?page=debug&camera=${camera}`)} + onClick={ + isEnabled + ? () => navigate(`/settings?page=debug&camera=${camera}`) + : undefined + } >
Debug View
@@ -315,10 +339,10 @@ export default function LiveContextMenu({ {cameraGroup && cameraGroup !== "default" && ( <> - +
setShowSettings(true)} + onClick={isEnabled ? () => setShowSettings(true) : undefined} >
Streaming Settings
@@ -328,10 +352,10 @@ export default function LiveContextMenu({ {preferredLiveMode == "jsmpeg" && isRestreamed && ( <> - +
Reset
@@ -342,7 +366,7 @@ export default function LiveContextMenu({ <> - +
Notifications
@@ -382,10 +406,15 @@ export default function LiveContextMenu({ <> { - sendNotification("ON"); - sendNotificationSuspend(0); - }} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => { + sendNotification("ON"); + sendNotificationSuspend(0); + } + : undefined + } >
{notificationState === "ON" ? ( @@ -405,36 +434,71 @@ export default function LiveContextMenu({ Suspend for:

- handleSuspend("5")}> + handleSuspend("5") : undefined + } + > 5 minutes handleSuspend("10")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("10") + : undefined + } > 10 minutes handleSuspend("30")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("30") + : undefined + } > 30 minutes handleSuspend("60")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("60") + : undefined + } > 1 hour handleSuspend("840")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("840") + : undefined + } > 12 hours handleSuspend("1440")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("1440") + : undefined + } > 24 hours handleSuspend("off")} + disabled={!isEnabled} + onClick={ + isEnabled + ? () => handleSuspend("off") + : undefined + } > Until restart diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 4bd751469..f2b0639a4 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -22,6 +22,7 @@ import { TbExclamationCircle } from "react-icons/tb"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { baseUrl } from "@/api/baseUrl"; import { PlayerStats } from "./PlayerStats"; +import { LuVideoOff } from "react-icons/lu"; type LivePlayerProps = { cameraRef?: (ref: HTMLDivElement | null) => void; @@ -86,8 +87,13 @@ export default function LivePlayer({ // camera activity - const { activeMotion, activeTracking, objects, offline } = - useCameraActivity(cameraConfig); + const { + enabled: cameraEnabled, + activeMotion, + activeTracking, + objects, + offline, + } = useCameraActivity(cameraConfig); const cameraActive = useMemo( () => @@ -191,12 +197,37 @@ export default function LivePlayer({ setLiveReady(true); }, []); + // enabled states + + const [isReEnabling, setIsReEnabling] = useState(false); + const prevCameraEnabledRef = useRef(cameraEnabled); + + useEffect(() => { + if (!prevCameraEnabledRef.current && cameraEnabled) { + // Camera enabled + setLiveReady(false); + setIsReEnabling(true); + setKey((prevKey) => prevKey + 1); + } else if (prevCameraEnabledRef.current && !cameraEnabled) { + // Camera disabled + setLiveReady(false); + setKey((prevKey) => prevKey + 1); + } + prevCameraEnabledRef.current = cameraEnabled; + }, [cameraEnabled]); + + useEffect(() => { + if (liveReady && isReEnabling) { + setIsReEnabling(false); + } + }, [liveReady, isReEnabling]); + if (!cameraConfig) { return ; } let player; - if (!autoLive || !streamName) { + if (!autoLive || !streamName || !cameraEnabled) { player = null; } else if (preferredLiveMode == "webrtc") { player = ( @@ -267,6 +298,22 @@ export default function LivePlayer({ player = ; } + // if (cameraConfig.name == "lpr") + // console.log( + // cameraConfig.name, + // "enabled", + // cameraEnabled, + // "prev enabled", + // prevCameraEnabledRef.current, + // "offline", + // offline, + // "show still", + // showStillWithoutActivity, + // "live ready", + // liveReady, + // player, + // ); + return (
- {((showStillWithoutActivity && !liveReady) || liveReady) && ( - <> -
-
- - )} + {cameraEnabled && + ((showStillWithoutActivity && !liveReady) || liveReady) && ( + <> +
+
+ + )} {player} - {!offline && !showStillWithoutActivity && !liveReady && ( - - )} + {cameraEnabled && + !offline && + (!showStillWithoutActivity || isReEnabling) && + !liveReady && } {((showStillWithoutActivity && !liveReady) || liveReady) && objects.length > 0 && ( @@ -344,7 +393,9 @@ export default function LivePlayer({
)} + {!cameraEnabled && ( +
+
+ +

+ Camera is disabled +

+
+
+ )} +
{autoLive && !offline && @@ -378,7 +440,7 @@ export default function LivePlayer({ ((showStillWithoutActivity && !liveReady) || liveReady) && ( )} - {offline && showStillWithoutActivity && ( + {((offline && showStillWithoutActivity) || !cameraEnabled) && ( diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 247ae8991..c6c5ee474 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -68,7 +68,7 @@ export default function ZoneEditPane({ } return Object.values(config.cameras) - .filter((conf) => conf.ui.dashboard && conf.enabled) + .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index bbf70ba32..14a575224 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -1,4 +1,5 @@ import { + useEnabledState, useFrigateEvents, useInitialCameraState, useMotionActivity, @@ -15,6 +16,7 @@ import useSWR from "swr"; import { getAttributeLabels } from "@/utils/iconUtil"; type useCameraActivityReturn = { + enabled: boolean; activeTracking: boolean; activeMotion: boolean; objects: ObjectType[]; @@ -56,6 +58,7 @@ export function useCameraActivity( [objects], ); + const { payload: cameraEnabled } = useEnabledState(camera.name); const { payload: detectingMotion } = useMotionActivity(camera.name); const { payload: event } = useFrigateEvents(); const updatedEvent = useDeepMemo(event); @@ -145,12 +148,17 @@ export function useCameraActivity( return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60; }, [camera, stats]); + const isCameraEnabled = cameraEnabled === "ON"; + return { - activeTracking: hasActiveObjects, - activeMotion: detectingMotion - ? detectingMotion === "ON" - : updatedCameraState?.motion === true, - objects, + enabled: isCameraEnabled, + activeTracking: isCameraEnabled ? hasActiveObjects : false, + activeMotion: isCameraEnabled + ? detectingMotion + ? detectingMotion === "ON" + : updatedCameraState?.motion === true + : false, + objects: isCameraEnabled ? objects : [], offline, }; } diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 97e565ef1..016f3cba1 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -101,12 +101,14 @@ function Live() { ) { const group = config.camera_groups[cameraGroup]; return Object.values(config.cameras) - .filter((conf) => conf.enabled && group.cameras.includes(conf.name)) + .filter( + (conf) => conf.enabled_in_config && group.cameras.includes(conf.name), + ) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); } return Object.values(config.cameras) - .filter((conf) => conf.ui.dashboard && conf.enabled) + .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config, cameraGroup]); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 6eeb5bcc3..33f854ba3 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -39,6 +39,7 @@ import SearchSettingsView from "@/views/settings/SearchSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; +import { useInitialCameraState } from "@/api/ws"; const allSettingsViews = [ "UI settings", @@ -71,12 +72,33 @@ export default function Settings() { } return Object.values(config.cameras) - .filter((conf) => conf.ui.dashboard && conf.enabled) + .filter((conf) => conf.ui.dashboard && conf.enabled_in_config) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); const [selectedCamera, setSelectedCamera] = useState(""); + const { payload: allCameraStates } = useInitialCameraState( + cameras.length > 0 ? cameras[0].name : "", + true, + ); + + const cameraEnabledStates = useMemo(() => { + const states: Record = {}; + if (allCameraStates) { + Object.entries(allCameraStates).forEach(([camName, state]) => { + states[camName] = state.config?.enabled ?? false; + }); + } + // fallback to config if ws data isn’t available yet + cameras.forEach((cam) => { + if (!(cam.name in states)) { + states[cam.name] = cam.enabled; + } + }); + return states; + }, [allCameraStates, cameras]); + const [filterZoneMask, setFilterZoneMask] = useState(); const handleDialog = useCallback( @@ -91,10 +113,25 @@ export default function Settings() { ); useEffect(() => { - if (cameras.length > 0 && selectedCamera === "") { - setSelectedCamera(cameras[0].name); + if (cameras.length > 0) { + if (!selectedCamera) { + // Set to first enabled camera initially if no selection + const firstEnabledCamera = + cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; + setSelectedCamera(firstEnabledCamera.name); + } else if ( + !cameraEnabledStates[selectedCamera] && + page !== "camera settings" + ) { + // Switch to first enabled camera if current one is disabled, unless on "camera settings" page + const firstEnabledCamera = + cameras.find((cam) => cameraEnabledStates[cam.name]) || cameras[0]; + if (firstEnabledCamera.name !== selectedCamera) { + setSelectedCamera(firstEnabledCamera.name); + } + } } - }, [cameras, selectedCamera]); + }, [cameras, selectedCamera, cameraEnabledStates, page]); useEffect(() => { if (tabsRef.current) { @@ -177,6 +214,8 @@ export default function Settings() { allCameras={cameras} selectedCamera={selectedCamera} setSelectedCamera={setSelectedCamera} + cameraEnabledStates={cameraEnabledStates} + currentPage={page} />
)} @@ -244,17 +283,21 @@ type CameraSelectButtonProps = { allCameras: CameraConfig[]; selectedCamera: string; setSelectedCamera: React.Dispatch>; + cameraEnabledStates: Record; + currentPage: SettingsType; }; function CameraSelectButton({ allCameras, selectedCamera, setSelectedCamera, + cameraEnabledStates, + currentPage, }: CameraSelectButtonProps) { const [open, setOpen] = useState(false); if (!allCameras.length) { - return; + return null; } const trigger = ( @@ -283,19 +326,24 @@ function CameraSelectButton({ )}
- {allCameras.map((item) => ( - { - if (isChecked) { - setSelectedCamera(item.name); - setOpen(false); - } - }} - /> - ))} + {allCameras.map((item) => { + const isEnabled = cameraEnabledStates[item.name]; + const isCameraSettingsPage = currentPage === "camera settings"; + return ( + { + if (isChecked && (isEnabled || isCameraSettingsPage)) { + setSelectedCamera(item.name); + setOpen(false); + } + }} + disabled={!isEnabled && !isCameraSettingsPage} + /> + ); + })}
diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 4ec4de853..e468c534f 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -57,6 +57,7 @@ export interface CameraConfig { width: number; }; enabled: boolean; + enabled_in_config: boolean; ffmpeg: { global_args: string[]; hwaccel_args: string; diff --git a/web/src/types/ws.ts b/web/src/types/ws.ts index 397b213f6..2590d45a7 100644 --- a/web/src/types/ws.ts +++ b/web/src/types/ws.ts @@ -52,6 +52,7 @@ export type ObjectType = { }; export interface FrigateCameraState { + enabled: boolean; motion: boolean; objects: ObjectType[]; } diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index ccf06de7b..9b45c5a60 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -2,6 +2,7 @@ import { useAudioState, useAutotrackingState, useDetectState, + useEnabledState, usePtzCommand, useRecordingsState, useSnapshotsState, @@ -82,6 +83,8 @@ import { LuHistory, LuInfo, LuPictureInPicture, + LuPower, + LuPowerOff, LuVideo, LuVideoOff, LuX, @@ -185,6 +188,10 @@ export default function LiveCameraView({ ); }, [cameraMetadata]); + // camera enabled state + const { payload: enabledState } = useEnabledState(camera.name); + const cameraEnabled = enabledState === "ON"; + // click overlay for ptzs const [clickOverlay, setClickOverlay] = useState(false); @@ -470,6 +477,7 @@ export default function LiveCameraView({ setPip(false); } }} + disabled={!cameraEnabled} /> )} {supports2WayTalk && ( @@ -481,11 +489,11 @@ export default function LiveCameraView({ title={`${mic ? "Disable" : "Enable"} Two Way Talk`} onClick={() => { setMic(!mic); - // Turn on audio when enabling the mic if audio is currently off if (!mic && !audio) { setAudio(true); } }} + disabled={!cameraEnabled} /> )} {supportsAudioOutput && preferredLiveMode != "jsmpeg" && ( @@ -496,6 +504,7 @@ export default function LiveCameraView({ isActive={audio ?? false} title={`${audio ? "Disable" : "Enable"} Camera Audio`} onClick={() => setAudio(!audio)} + disabled={!cameraEnabled} /> )}
@@ -913,6 +923,7 @@ type FrigateCameraFeaturesProps = { setLowBandwidth: React.Dispatch>; supportsAudioOutput: boolean; supports2WayTalk: boolean; + cameraEnabled: boolean; }; function FrigateCameraFeatures({ camera, @@ -931,10 +942,14 @@ function FrigateCameraFeatures({ setLowBandwidth, supportsAudioOutput, supports2WayTalk, + cameraEnabled, }: FrigateCameraFeaturesProps) { const { payload: detectState, send: sendDetect } = useDetectState( camera.name, ); + const { payload: enabledState, send: sendEnabled } = useEnabledState( + camera.name, + ); const { payload: recordState, send: sendRecord } = useRecordingsState( camera.name, ); @@ -1043,6 +1058,15 @@ function FrigateCameraFeatures({ if (isDesktop || isTablet) { return ( <> + sendEnabled(enabledState == "ON" ? "OFF" : "ON")} + disabled={false} + /> sendDetect(detectState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> sendRecord(recordState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> {audioDetectEnabled && ( sendAudio(audioState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} /> )} {autotrackingEnabled && ( @@ -1087,6 +1115,7 @@ function FrigateCameraFeatures({ onClick={() => sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") } + disabled={!cameraEnabled} /> )} diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index fa9d0ba58..e2c1ca563 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -29,7 +29,7 @@ import { MdCircle } from "react-icons/md"; import { cn } from "@/lib/utils"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; -import { useAlertsState, useDetectionsState } from "@/api/ws"; +import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws"; type CameraSettingsViewProps = { selectedCamera: string; @@ -108,6 +108,8 @@ export default function CameraSettingsView({ const watchedAlertsZones = form.watch("alerts_zones"); const watchedDetectionsZones = form.watch("detections_zones"); + const { payload: enabledState, send: sendEnabled } = + useEnabledState(selectedCamera); const { payload: alertsState, send: sendAlerts } = useAlertsState(selectedCamera); const { payload: detectionsState, send: sendDetections } = @@ -252,6 +254,31 @@ export default function CameraSettingsView({ + + Streams + + +
+ { + sendEnabled(isChecked ? "ON" : "OFF"); + }} + /> +
+ +
+
+
+ Disabling a camera completely stops Frigate's processing of this + camera's streams. Detection, recording, and debugging will be + unavailable. +
Note: This does not disable go2rtc restreams. +
+ + Review diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index edae6ba28..fcda4adb1 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -80,7 +80,7 @@ export default function NotificationView({ return Object.values(config.cameras) .filter( (conf) => - conf.enabled && + conf.enabled_in_config && conf.notifications && conf.notifications.enabled_in_config, ) From f3765bc391eb629b049fc494544a9a1dc8ce4120 Mon Sep 17 00:00:00 2001 From: leccelecce <24962424+leccelecce@users.noreply.github.com> Date: Mon, 3 Mar 2025 17:01:02 +0000 Subject: [PATCH 16/82] GenAI minor refactor (#16916) --- frigate/embeddings/maintainer.py | 200 +++++++++++++++---------------- 1 file changed, 96 insertions(+), 104 deletions(-) diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index c9b6062c9..dfaed532e 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -293,6 +293,7 @@ class EmbeddingMaintainer(threading.Thread): # Embed the thumbnail self._embed_thumbnail(event_id, thumbnail) + # Run GenAI if ( camera_config.genai.enabled and self.genai_client is not None @@ -306,82 +307,7 @@ class EmbeddingMaintainer(threading.Thread): or set(event.zones) & set(camera_config.genai.required_zones) ) ): - if event.has_snapshot and camera_config.genai.use_snapshot: - with open( - os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg"), - "rb", - ) as image_file: - snapshot_image = image_file.read() - - img = cv2.imdecode( - np.frombuffer(snapshot_image, dtype=np.int8), - cv2.IMREAD_COLOR, - ) - - # crop snapshot based on region before sending off to genai - height, width = img.shape[:2] - x1_rel, y1_rel, width_rel, height_rel = event.data["region"] - - x1, y1 = int(x1_rel * width), int(y1_rel * height) - cropped_image = img[ - y1 : y1 + int(height_rel * height), - x1 : x1 + int(width_rel * width), - ] - - _, buffer = cv2.imencode(".jpg", cropped_image) - snapshot_image = buffer.tobytes() - - num_thumbnails = len(self.tracked_events.get(event_id, [])) - - embed_image = ( - [snapshot_image] - if event.has_snapshot and camera_config.genai.use_snapshot - else ( - [ - data["thumbnail"] - for data in self.tracked_events[event_id] - ] - if num_thumbnails > 0 - else [thumbnail] - ) - ) - - if camera_config.genai.debug_save_thumbnails and num_thumbnails > 0: - logger.debug( - f"Saving {num_thumbnails} thumbnails for event {event.id}" - ) - - Path( - os.path.join(CLIPS_DIR, f"genai-requests/{event.id}") - ).mkdir(parents=True, exist_ok=True) - - for idx, data in enumerate(self.tracked_events[event_id], 1): - jpg_bytes: bytes = data["thumbnail"] - - if jpg_bytes is None: - logger.warning( - f"Unable to save thumbnail {idx} for {event.id}." - ) - else: - with open( - os.path.join( - CLIPS_DIR, - f"genai-requests/{event.id}/{idx}.jpg", - ), - "wb", - ) as j: - j.write(jpg_bytes) - - # Generate the description. Call happens in a thread since it is network bound. - threading.Thread( - target=self._embed_description, - name=f"_embed_description_{event.id}", - daemon=True, - args=( - event, - embed_image, - ), - ).start() + self._process_genai_description(event, camera_config, thumbnail) # Delete tracked events based on the event_id if event_id in self.tracked_events: @@ -440,7 +366,58 @@ class EmbeddingMaintainer(threading.Thread): self.embeddings.embed_thumbnail(event_id, thumbnail) - def _embed_description(self, event: Event, thumbnails: list[bytes]) -> None: + def _process_genai_description(self, event, camera_config, thumbnail) -> None: + if event.has_snapshot and camera_config.genai.use_snapshot: + snapshot_image = self._read_and_crop_snapshot(event, camera_config) + if not snapshot_image: + return + + num_thumbnails = len(self.tracked_events.get(event.id, [])) + + embed_image = ( + [snapshot_image] + if event.has_snapshot and camera_config.genai.use_snapshot + else ( + [data["thumbnail"] for data in self.tracked_events[event.id]] + if num_thumbnails > 0 + else [thumbnail] + ) + ) + + if camera_config.genai.debug_save_thumbnails and num_thumbnails > 0: + logger.debug(f"Saving {num_thumbnails} thumbnails for event {event.id}") + + Path(os.path.join(CLIPS_DIR, f"genai-requests/{event.id}")).mkdir( + parents=True, exist_ok=True + ) + + for idx, data in enumerate(self.tracked_events[event.id], 1): + jpg_bytes: bytes = data["thumbnail"] + + if jpg_bytes is None: + logger.warning(f"Unable to save thumbnail {idx} for {event.id}.") + else: + with open( + os.path.join( + CLIPS_DIR, + f"genai-requests/{event.id}/{idx}.jpg", + ), + "wb", + ) as j: + j.write(jpg_bytes) + + # Generate the description. Call happens in a thread since it is network bound. + threading.Thread( + target=self._genai_embed_description, + name=f"_genai_embed_description_{event.id}", + daemon=True, + args=( + event, + embed_image, + ), + ).start() + + def _genai_embed_description(self, event: Event, thumbnails: list[bytes]) -> None: """Embed the description for an event.""" camera_config = self.config.cameras[event.camera] @@ -473,6 +450,45 @@ class EmbeddingMaintainer(threading.Thread): description, ) + def _read_and_crop_snapshot(self, event: Event, camera_config) -> bytes | None: + """Read, decode, and crop the snapshot image.""" + + snapshot_file = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg") + + if not os.path.isfile(snapshot_file): + logger.error( + f"Cannot load snapshot for {event.id}, file not found: {snapshot_file}" + ) + return None + + try: + with open(snapshot_file, "rb") as image_file: + snapshot_image = image_file.read() + + img = cv2.imdecode( + np.frombuffer(snapshot_image, dtype=np.int8), + cv2.IMREAD_COLOR, + ) + + # Crop snapshot based on region + # provide full image if region doesn't exist (manual events) + height, width = img.shape[:2] + x1_rel, y1_rel, width_rel, height_rel = event.data.get( + "region", [0, 0, 1, 1] + ) + x1, y1 = int(x1_rel * width), int(y1_rel * height) + + cropped_image = img[ + y1 : y1 + int(height_rel * height), + x1 : x1 + int(width_rel * width), + ] + + _, buffer = cv2.imencode(".jpg", cropped_image) + + return buffer.tobytes() + except Exception: + return None + def handle_regenerate_description(self, event_id: str, source: str) -> None: try: event: Event = Event.get(Event.id == event_id) @@ -492,34 +508,10 @@ class EmbeddingMaintainer(threading.Thread): ) if event.has_snapshot and source == "snapshot": - snapshot_file = os.path.join(CLIPS_DIR, f"{event.camera}-{event.id}.jpg") - - if not os.path.isfile(snapshot_file): - logger.error( - f"Cannot regenerate description for {event.id}, snapshot file not found: {snapshot_file}" - ) + snapshot_image = self._read_and_crop_snapshot(event, camera_config) + if not snapshot_image: return - with open(snapshot_file, "rb") as image_file: - snapshot_image = image_file.read() - img = cv2.imdecode( - np.frombuffer(snapshot_image, dtype=np.int8), cv2.IMREAD_COLOR - ) - - # crop snapshot based on region before sending off to genai - # provide full image if region doesn't exist (manual events) - region = event.data.get("region", [0, 0, 1, 1]) - height, width = img.shape[:2] - x1_rel, y1_rel, width_rel, height_rel = region - - x1, y1 = int(x1_rel * width), int(y1_rel * height) - cropped_image = img[ - y1 : y1 + int(height_rel * height), x1 : x1 + int(width_rel * width) - ] - - _, buffer = cv2.imencode(".jpg", cropped_image) - snapshot_image = buffer.tobytes() - embed_image = ( [snapshot_image] if event.has_snapshot and source == "snapshot" @@ -530,4 +522,4 @@ class EmbeddingMaintainer(threading.Thread): ) ) - self._embed_description(event, embed_image) + self._genai_embed_description(event, embed_image) From 180b0af3c9c28172e8401123961258079ef96393 Mon Sep 17 00:00:00 2001 From: D34DC3N73R Date: Mon, 3 Mar 2025 11:53:24 -0800 Subject: [PATCH 17/82] Adapt openai.py to work with xAI (#16903) * Adapt openai.py to work with xAI It appears xAI is a bit more strict in regards to how the prompt is sent. This changes the prompt to be a dictionary with `"type": "text"` which works with OpenAI and xAI. * Adapt openai.py to work with xAI add "detail": "low" * Adapt openai.py to work with xAI Apply Ruff formatting and linting fixes --- frigate/genai/openai.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/frigate/genai/openai.py b/frigate/genai/openai.py index 4568905a3..4b1926099 100644 --- a/frigate/genai/openai.py +++ b/frigate/genai/openai.py @@ -26,23 +26,30 @@ class OpenAIClient(GenAIClient): def _send(self, prompt: str, images: list[bytes]) -> Optional[str]: """Submit a request to OpenAI.""" encoded_images = [base64.b64encode(image).decode("utf-8") for image in images] + messages_content = [] + for image in encoded_images: + messages_content.append( + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{image}", + "detail": "low", + }, + } + ) + messages_content.append( + { + "type": "text", + "text": prompt, + } + ) try: result = self.provider.chat.completions.create( model=self.genai_config.model, messages=[ { "role": "user", - "content": [ - { - "type": "image_url", - "image_url": { - "url": f"data:image/jpeg;base64,{image}", - "detail": "low", - }, - } - for image in encoded_images - ] - + [prompt], + "content": messages_content, }, ], timeout=self.timeout, From 2946c935eea3d492493278f621000d0806594920 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 3 Mar 2025 14:05:49 -0700 Subject: [PATCH 18/82] Disabled camera output (#16920) * Fix live cameras not showing on refresh * Fix live dashboard when birdseye is added * Handle cameras that are offline / disabled * Use black instead of green frame * Fix missing mqtt topics --- frigate/comms/mqtt.py | 6 ++ frigate/output/birdseye.py | 42 +++++++++----- frigate/output/output.py | 66 ++++++++++++++++------ frigate/util/image.py | 16 ++++++ web/src/components/player/LivePlayer.tsx | 2 +- web/src/views/live/DraggableGridLayout.tsx | 10 ++-- web/src/views/live/LiveDashboardView.tsx | 10 ++-- 7 files changed, 110 insertions(+), 42 deletions(-) diff --git a/frigate/comms/mqtt.py b/frigate/comms/mqtt.py index 9e11a0af1..316813518 100644 --- a/frigate/comms/mqtt.py +++ b/frigate/comms/mqtt.py @@ -43,6 +43,11 @@ class MqttClient(Communicator): # type: ignore[misc] def _set_initial_topics(self) -> None: """Set initial state topics.""" for camera_name, camera in self.config.cameras.items(): + self.publish( + f"{camera_name}/enabled/state", + "ON" if camera.enabled_in_config else "OFF", + retain=True, + ) self.publish( f"{camera_name}/recordings/state", "ON" if camera.record.enabled_in_config else "OFF", @@ -196,6 +201,7 @@ class MqttClient(Communicator): # type: ignore[misc] # register callbacks callback_types = [ + "enabled", "recordings", "snapshots", "detect", diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index 3d036e9d5..cd4aa26ec 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -390,8 +390,11 @@ class BirdsEyeFrameManager: def _get_enabled_state(self, camera: str) -> bool: """Fetch the latest enabled state for a camera from ZMQ.""" _, config_data = self.enabled_subscribers[camera].check_for_update() + if config_data: + self.config.cameras[camera].enabled = config_data.enabled return config_data.enabled + return self.config.cameras[camera].enabled def update_frame(self, frame: Optional[np.ndarray] = None) -> bool: @@ -704,15 +707,17 @@ class BirdsEyeFrameManager: ) -> bool: # don't process if birdseye is disabled for this camera camera_config = self.config.cameras[camera].birdseye + force_update = False # disabling birdseye is a little tricky - if not camera_config.enabled or not self._get_enabled_state(camera): + if not self._get_enabled_state(camera): # if we've rendered a frame (we have a value for last_active_frame) # then we need to set it to zero if self.cameras[camera]["last_active_frame"] > 0: self.cameras[camera]["last_active_frame"] = 0 - - return False + force_update = True + else: + return False # update the last active frame for the camera self.cameras[camera]["current_frame"] = frame.copy() @@ -723,7 +728,7 @@ class BirdsEyeFrameManager: now = datetime.datetime.now().timestamp() # limit output to 10 fps - if (now - self.last_output_time) < 1 / 10: + if not force_update and (now - self.last_output_time) < 1 / 10: return False try: @@ -735,7 +740,7 @@ class BirdsEyeFrameManager: print(traceback.format_exc()) # if the frame was updated or the fps is too low, send frame - if updated_frame or (now - self.last_output_time) > 1: + if force_update or updated_frame or (now - self.last_output_time) > 1: self.last_output_time = now return True return False @@ -783,6 +788,22 @@ class Birdseye: self.converter.start() self.broadcaster.start() + def __send_new_frame(self) -> None: + frame_bytes = self.birdseye_manager.frame.tobytes() + + if self.config.birdseye.restream: + self.birdseye_buffer[:] = frame_bytes + + try: + self.input.put_nowait(frame_bytes) + except queue.Full: + # drop frames if queue is full + pass + + def all_cameras_disabled(self) -> None: + self.birdseye_manager.clear_frame() + self.__send_new_frame() + def write_data( self, camera: str, @@ -811,16 +832,7 @@ class Birdseye: frame_time, frame, ): - frame_bytes = self.birdseye_manager.frame.tobytes() - - if self.config.birdseye.restream: - self.birdseye_buffer[:] = frame_bytes - - try: - self.input.put_nowait(frame_bytes) - except queue.Full: - # drop frames if queue is full - pass + self.__send_new_frame() def stop(self) -> None: self.config_subscriber.stop() diff --git a/frigate/output/output.py b/frigate/output/output.py index 9beb87250..e0e64e298 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -1,12 +1,12 @@ """Handle outputting raw frigate frames""" +import datetime import logging import multiprocessing as mp import os import shutil import signal import threading -from typing import Optional from wsgiref.simple_server import make_server from setproctitle import setproctitle @@ -25,11 +25,43 @@ from frigate.const import CACHE_DIR, CLIPS_DIR from frigate.output.birdseye import Birdseye from frigate.output.camera import JsmpegCamera from frigate.output.preview import PreviewRecorder -from frigate.util.image import SharedMemoryFrameManager +from frigate.util.image import SharedMemoryFrameManager, get_blank_yuv_frame logger = logging.getLogger(__name__) +def check_disabled_camera_update( + config: FrigateConfig, + birdseye: Birdseye | None, + previews: dict[str, PreviewRecorder], + write_times: dict[str, float], +) -> None: + """Check if camera is disabled / offline and needs an update.""" + now = datetime.datetime.now().timestamp() + has_enabled_camera = False + + for camera, last_update in write_times.items(): + if config.cameras[camera].enabled: + has_enabled_camera = True + + if now - last_update > 1: + # last camera update was more than one second ago + # need to send empty data to updaters because current + # frame is now out of date + frame = get_blank_yuv_frame( + config.cameras[camera].detect.width, + config.cameras[camera].detect.height, + ) + + if birdseye: + birdseye.write_data(camera, [], [], now, frame) + + previews[camera].write_data([], [], now, frame) + + if not has_enabled_camera and birdseye: + birdseye.all_cameras_disabled() + + def output_frames( config: FrigateConfig, ): @@ -67,10 +99,11 @@ def output_frames( } jsmpeg_cameras: dict[str, JsmpegCamera] = {} - birdseye: Optional[Birdseye] = None + birdseye: Birdseye | None = None preview_recorders: dict[str, PreviewRecorder] = {} preview_write_times: dict[str, float] = {} failed_frame_requests: dict[str, int] = {} + last_disabled_cam_check = datetime.datetime.now().timestamp() move_preview_frames("cache") @@ -89,13 +122,23 @@ def output_frames( def get_enabled_state(camera: str) -> bool: _, config_data = enabled_subscribers[camera].check_for_update() + if config_data: + config.cameras[camera].enabled = config_data.enabled return config_data.enabled - # default + return config.cameras[camera].enabled while not stop_event.is_set(): (topic, data) = detection_subscriber.check_for_update(timeout=1) + now = datetime.datetime.now().timestamp() + + if now - last_disabled_cam_check > 5: + # check disabled cameras every 5 seconds + last_disabled_cam_check = now + check_disabled_camera_update( + config, birdseye, preview_recorders, preview_write_times + ) if not topic: continue @@ -151,23 +194,10 @@ def output_frames( ) # send frames for low fps recording - generated_preview = preview_recorders[camera].write_data( + preview_recorders[camera].write_data( current_tracked_objects, motion_boxes, frame_time, frame ) preview_write_times[camera] = frame_time - - # if another camera generated a preview, - # check for any cameras that are currently offline - # and need to generate a preview - if generated_preview: - logger.debug( - "Checking for offline cameras because another camera generated a preview." - ) - for camera, time in preview_write_times.copy().items(): - if time != 0 and frame_time - time > 10: - preview_recorders[camera].flag_offline(frame_time) - preview_write_times[camera] = frame_time - frame_manager.close(frame_name) move_preview_frames("clips") diff --git a/frigate/util/image.py b/frigate/util/image.py index 7e4915821..20806372c 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -632,6 +632,22 @@ def copy_yuv_to_position( ) +def get_blank_yuv_frame(width: int, height: int) -> np.ndarray: + """Creates a black YUV 4:2:0 frame.""" + yuv_height = height * 3 // 2 + yuv_frame = np.zeros((yuv_height, width), dtype=np.uint8) + + uv_height = height // 2 + + # The U and V planes are stored after the Y plane. + u_start = height # U plane starts right after Y plane + v_start = u_start + uv_height // 2 # V plane starts after U plane + yuv_frame[u_start : u_start + uv_height, :width] = 128 + yuv_frame[v_start : v_start + uv_height, :width] = 128 + + return yuv_frame + + def yuv_region_2_yuv(frame, region): try: # TODO: does this copy the numpy array? diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index f2b0639a4..913373774 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -200,7 +200,7 @@ export default function LivePlayer({ // enabled states const [isReEnabling, setIsReEnabling] = useState(false); - const prevCameraEnabledRef = useRef(cameraEnabled); + const prevCameraEnabledRef = useRef(cameraEnabled ?? true); useEffect(() => { if (!prevCameraEnabledRef.current && cameraEnabled) { diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 3b85de4b3..d0da3e5ac 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -396,10 +396,12 @@ export default function DraggableGridLayout({ const initialVolumeStates: VolumeState = {}; Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { - Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { - initialAudioStates[camera] = cameraSettings.playAudio ?? false; - initialVolumeStates[camera] = cameraSettings.volume ?? 1; - }); + if (groupSettings) { + Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { + initialAudioStates[camera] = cameraSettings.playAudio ?? false; + initialVolumeStates[camera] = cameraSettings.volume ?? 1; + }); + } }); setAudioStates(initialAudioStates); diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 45d0d5302..e59fd96ca 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -268,10 +268,12 @@ export default function LiveDashboardView({ const initialVolumeStates: VolumeState = {}; Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { - Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { - initialAudioStates[camera] = cameraSettings.playAudio ?? false; - initialVolumeStates[camera] = cameraSettings.volume ?? 1; - }); + if (groupSettings) { + Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { + initialAudioStates[camera] = cameraSettings.playAudio ?? false; + initialVolumeStates[camera] = cameraSettings.volume ?? 1; + }); + } }); setAudioStates(initialAudioStates); From 56079d080dbd9d414adb5b4be893d576ecfaca86 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 3 Mar 2025 16:28:34 -0700 Subject: [PATCH 19/82] Quick fix (#16926) * fix * Fix * Fix incorrect default websocket value * Cleanup value setting --- frigate/object_processing.py | 14 ++++++-------- web/src/api/ws.tsx | 2 +- web/src/components/player/LivePlayer.tsx | 3 +++ web/src/hooks/use-camera-activity.ts | 4 ++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 783c2b2d0..a7a2fb066 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -707,16 +707,14 @@ class TrackedObjectProcessor(threading.Thread): def _get_enabled_state(self, camera: str) -> bool: _, config_data = self.enabled_subscribers[camera].check_for_update() + if config_data: - enabled = config_data.enabled + self.config.cameras[camera].enabled = config_data.enabled + if self.camera_states[camera].prev_enabled is None: - self.camera_states[camera].prev_enabled = enabled - return enabled - return ( - self.camera_states[camera].prev_enabled - if self.camera_states[camera].prev_enabled is not None - else self.config.cameras[camera].enabled - ) + self.camera_states[camera].prev_enabled = config_data.enabled + + return self.config.cameras[camera].enabled def run(self): while not self.stop_event.is_set(): diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 27600993a..5eedcdbcd 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -174,7 +174,7 @@ export function useEnabledState(camera: string): { value: { payload }, send, } = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`); - return { payload: payload as ToggleableSetting, send }; + return { payload: (payload ?? "ON") as ToggleableSetting, send }; } export function useDetectState(camera: string): { diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 913373774..ae9fd6197 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -203,6 +203,9 @@ export default function LivePlayer({ const prevCameraEnabledRef = useRef(cameraEnabled ?? true); useEffect(() => { + if (cameraEnabled == undefined) { + return; + } if (!prevCameraEnabledRef.current && cameraEnabled) { // Camera enabled setLiveReady(false); diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index 14a575224..28eb8c67d 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -16,7 +16,7 @@ import useSWR from "swr"; import { getAttributeLabels } from "@/utils/iconUtil"; type useCameraActivityReturn = { - enabled: boolean; + enabled?: boolean; activeTracking: boolean; activeMotion: boolean; objects: ObjectType[]; @@ -148,7 +148,7 @@ export function useCameraActivity( return cameras[camera.name].camera_fps == 0 && stats["service"].uptime > 60; }, [camera, stats]); - const isCameraEnabled = cameraEnabled === "ON"; + const isCameraEnabled = cameraEnabled ? cameraEnabled === "ON" : undefined; return { enabled: isCameraEnabled, From 5210d8c0a296c269b828233bf2361bc485baf65c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 3 Mar 2025 18:41:28 -0600 Subject: [PATCH 20/82] Add camera enable switch to mobile drawer (#16929) --- web/src/views/live/LiveCameraView.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 9b45c5a60..96a0ed2bd 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -1399,6 +1399,13 @@ function FrigateCameraFeatures({
+ + sendEnabled(enabledState == "ON" ? "OFF" : "ON") + } + /> Date: Tue, 4 Mar 2025 22:19:40 +0900 Subject: [PATCH 21/82] Fixed the issue where internal context copy occurs frequently. (#16931) remove cache mount in nginx build Co-authored-by: Ludis Hur --- docker/main/Dockerfile | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 674add58e..7a0351240 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -39,10 +39,7 @@ ARG DEBIAN_FRONTEND ENV CCACHE_DIR /root/.ccache ENV CCACHE_MAXSIZE 2G -# bind /var/cache/apt to tmpfs to speed up nginx build -RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \ - --mount=type=bind,source=docker/main/build_nginx.sh,target=/deps/build_nginx.sh \ - --mount=type=cache,target=/root/.ccache \ +RUN --mount=type=bind,source=docker/main/build_nginx.sh,target=/deps/build_nginx.sh \ /deps/build_nginx.sh FROM wget AS sqlite-vec From 76c35307b2ac4689ece53016354fad872c465b78 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 4 Mar 2025 08:34:19 -0600 Subject: [PATCH 22/82] Ensure genai thumbnails are always jpegs (#16939) --- frigate/embeddings/maintainer.py | 12 +++++++++++- frigate/util/image.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index dfaed532e..b19a626b2 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -48,7 +48,11 @@ from frigate.genai import get_genai_client from frigate.models import Event from frigate.types import TrackedObjectUpdateTypesEnum from frigate.util.builtin import serialize -from frigate.util.image import SharedMemoryFrameManager, calculate_region +from frigate.util.image import ( + SharedMemoryFrameManager, + calculate_region, + ensure_jpeg_bytes, +) from frigate.util.path import get_event_thumbnail_bytes from .embeddings import Embeddings @@ -374,6 +378,9 @@ class EmbeddingMaintainer(threading.Thread): num_thumbnails = len(self.tracked_events.get(event.id, [])) + # ensure we have a jpeg to pass to the model + thumbnail = ensure_jpeg_bytes(thumbnail) + embed_image = ( [snapshot_image] if event.has_snapshot and camera_config.genai.use_snapshot @@ -503,6 +510,9 @@ class EmbeddingMaintainer(threading.Thread): thumbnail = get_event_thumbnail_bytes(event) + # ensure we have a jpeg to pass to the model + thumbnail = ensure_jpeg_bytes(thumbnail) + logger.debug( f"Trying {source} regeneration for {event}, has_snapshot: {event.has_snapshot}" ) diff --git a/frigate/util/image.py b/frigate/util/image.py index 20806372c..0b80efe88 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -975,3 +975,22 @@ def get_histogram(image, x_min, y_min, x_max, y_max): [image_bgr], [0, 1, 2], None, [8, 8, 8], [0, 256, 0, 256, 0, 256] ) return cv2.normalize(hist, hist).flatten() + + +def ensure_jpeg_bytes(image_data): + """Ensure image data is jpeg bytes for genai""" + try: + img_array = np.frombuffer(image_data, dtype=np.uint8) + img = cv2.imdecode(img_array, cv2.IMREAD_COLOR) + + if img is None: + return image_data + + success, encoded_img = cv2.imencode(".jpg", img) + + if success: + return encoded_img.tobytes() + except Exception as e: + logger.warning(f"Error when converting thumbnail to jpeg for genai: {e}") + + return image_data From c23653338f699f8b88d0035fa6209545c7cf5ea9 Mon Sep 17 00:00:00 2001 From: leccelecce <24962424+leccelecce@users.noreply.github.com> Date: Tue, 4 Mar 2025 16:23:51 +0000 Subject: [PATCH 23/82] GenAI: allow configuring additional send trigger after_significant_updates as well as event_end (#16919) --- docs/docs/configuration/genai.md | 11 +++++++- docs/docs/configuration/reference.md | 6 +++++ frigate/config/camera/genai.py | 15 +++++++++++ frigate/embeddings/maintainer.py | 40 +++++++++++++++++++++++++++- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/docs/docs/configuration/genai.md b/docs/docs/configuration/genai.md index e46107a82..ec733684f 100644 --- a/docs/docs/configuration/genai.md +++ b/docs/docs/configuration/genai.md @@ -5,7 +5,7 @@ title: Generative AI Generative AI can be used to automatically generate descriptive text based on the thumbnails of your tracked objects. This helps with [Semantic Search](/configuration/semantic_search) in Frigate to provide more context about your tracked objects. Descriptions are accessed via the _Explore_ view in the Frigate UI by clicking on a tracked object's thumbnail. -Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle. Descriptions can also be regenerated manually via the Frigate UI. +Requests for a description are sent off automatically to your AI provider at the end of the tracked object's lifecycle, or can optionally be sent earlier after a number of significantly changed frames, for example in use in more real-time notifications. Descriptions can also be regenerated manually via the Frigate UI. Note that if you are manually entering a description for tracked objects prior to its end, this will be overwritten by the generated response. ## Configuration @@ -148,6 +148,15 @@ While generating simple descriptions of detected objects is useful, understandin Frigate provides an [MQTT topic](/integrations/mqtt), `frigate/tracked_object_update`, that is updated with a JSON payload containing `event_id` and `description` when your AI provider returns a description for a tracked object. This description could be used directly in notifications, such as sending alerts to your phone or making audio announcements. If additional details from the tracked object are needed, you can query the [HTTP API](/integrations/api/event-events-event-id-get) using the `event_id`, eg: `http://frigate_ip:5000/api/events/`. +If looking to get notifications earlier than when an object ceases to be tracked, an additional send trigger can be configured of `after_significant_updates`. + +```yaml +genai: + send_triggers: + tracked_object_end: true # default + after_significant_updates: 3 # how many updates to a tracked object before we should send an image +``` + ## Custom Prompts Frigate sends multiple frames from the tracked object along with a prompt to your Generative AI provider asking it to generate a description. The default prompt is as follows: diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index b53d9268f..47d1ccb07 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -813,6 +813,12 @@ cameras: - cat # Optional: Restrict generation to objects that entered any of the listed zones (default: none, all zones qualify) required_zones: [] + # Optional: What triggers to use to send frames for a tracked object to generative AI (default: shown below) + send_triggers: + # Once the object is no longer tracked + tracked_object_end: True + # Optional: After X many significant updates are received (default: shown below) + after_significant_updates: None # Optional: Save thumbnails sent to generative AI for review/debugging purposes (default: shown below) debug_save_thumbnails: False diff --git a/frigate/config/camera/genai.py b/frigate/config/camera/genai.py index e6b327836..6ef93682b 100644 --- a/frigate/config/camera/genai.py +++ b/frigate/config/camera/genai.py @@ -16,6 +16,17 @@ class GenAIProviderEnum(str, Enum): ollama = "ollama" +class GenAISendTriggersConfig(BaseModel): + tracked_object_end: bool = Field( + default=True, title="Send once the object is no longer tracked." + ) + after_significant_updates: Optional[int] = Field( + default=None, + title="Send an early request to generative AI when X frames accumulated.", + ge=1, + ) + + # uses BaseModel because some global attributes are not available at the camera level class GenAICameraConfig(BaseModel): enabled: bool = Field(default=False, title="Enable GenAI for camera.") @@ -42,6 +53,10 @@ class GenAICameraConfig(BaseModel): default=False, title="Save thumbnails sent to generative AI for debugging purposes.", ) + send_triggers: GenAISendTriggersConfig = Field( + default_factory=GenAISendTriggersConfig, + title="What triggers to use to send frames to generative AI for a tracked object.", + ) @field_validator("required_zones", mode="before") @classmethod diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index b19a626b2..b3bd6c204 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -132,6 +132,7 @@ class EmbeddingMaintainer(threading.Thread): self.stop_event = stop_event self.tracked_events: dict[str, list[any]] = {} + self.early_request_sent: dict[str, bool] = {} self.genai_client = get_genai_client(config) # recordings data @@ -240,6 +241,43 @@ class EmbeddingMaintainer(threading.Thread): self.tracked_events[data["id"]].append(data) + # check if we're configured to send an early request after a minimum number of updates received + if ( + self.genai_client is not None + and camera_config.genai.send_triggers.after_significant_updates + ): + if ( + len(self.tracked_events.get(data["id"], [])) + >= camera_config.genai.send_triggers.after_significant_updates + and data["id"] not in self.early_request_sent + ): + if data["has_clip"] and data["has_snapshot"]: + event: Event = Event.get(Event.id == data["id"]) + + if ( + not camera_config.genai.objects + or event.label in camera_config.genai.objects + ) and ( + not camera_config.genai.required_zones + or set(data["entered_zones"]) + & set(camera_config.genai.required_zones) + ): + logger.debug(f"{camera} sending early request to GenAI") + + self.early_request_sent[data["id"]] = True + threading.Thread( + target=self._genai_embed_description, + name=f"_genai_embed_description_{event.id}", + daemon=True, + args=( + event, + [ + data["thumbnail"] + for data in self.tracked_events[data["id"]] + ], + ), + ).start() + self.frame_manager.close(frame_name) def _process_finalized(self) -> None: @@ -300,8 +338,8 @@ class EmbeddingMaintainer(threading.Thread): # Run GenAI if ( camera_config.genai.enabled + and camera_config.genai.send_triggers.tracked_object_end and self.genai_client is not None - and event.data.get("description") is None and ( not camera_config.genai.objects or event.label in camera_config.genai.objects From 389c707ad2ab30a1406345b7973fac735b453fb4 Mon Sep 17 00:00:00 2001 From: jdryden572 Date: Tue, 4 Mar 2025 15:30:34 -0500 Subject: [PATCH 24/82] Orient live camera feed for best screen fit when in fullscreen mode (#16947) * Change orientation in fullscreen to best fit video * Refactor effect to simplify, add more comments --- web/src/views/live/LiveCameraView.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 96a0ed2bd..cacdc7c1d 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -362,6 +362,28 @@ export default function LiveCameraView({ } }, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]); + // On mobile devices that support it, try to orient screen + // to best fit the camera feed in fullscreen mode + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const screenOrientation = screen.orientation as any; + if (!screenOrientation.lock || !screenOrientation.unlock) { + // Browser does not support ScreenOrientation APIs that we need + return; + } + + if (fullscreen) { + const orientationForBestFit = + cameraAspectRatio > 1 ? "landscape" : "portrait"; + + // If the current device doesn't support locking orientation, + // this promise will reject with an error that we can ignore + screenOrientation.lock(orientationForBestFit).catch(() => {}); + } + + return () => screenOrientation.unlock(); + }, [fullscreen, cameraAspectRatio]); + const handleError = useCallback( (e: LivePlayerError) => { if (e) { From 73f8c97d1da6acc65ec02daf3cb4202faee63d21 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 4 Mar 2025 18:29:11 -0600 Subject: [PATCH 25/82] Docs updates (#16949) * live and lpr docs updates * disabled clarity * more disable clarity * clarify sync_recordings --- docs/docs/configuration/cameras.md | 2 +- .../license_plate_recognition.md | 2 +- docs/docs/configuration/live.md | 26 ++++++++++++++----- docs/docs/configuration/record.md | 2 ++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index 50a8c6f93..f1a6b7bf5 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -7,7 +7,7 @@ title: Camera Configuration Several inputs can be configured for each camera and the role of each input can be mixed and matched based on your needs. This allows you to use a lower resolution stream for object detection, but create recordings from a higher resolution stream, or vice versa. -A camera is enabled by default but can be temporarily disabled by using `enabled: False`. Existing tracked objects and recordings can still be accessed. Live streams, recording and detecting are not working. Camera specific configurations will be used. +A camera is enabled by default but can be disabled by using `enabled: False`. Cameras that are disabled through the configuration file will not appear in the Frigate UI and will not consume system resources. Each role can only be assigned to one input per camera. The options for roles are as follows: diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index 103c3bf14..3fe1ee852 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -115,7 +115,7 @@ lpr: Ensure that: -- Your camera has a clear, well-lit view of the plate. +- Your camera has a clear, human-readable, well-lit view of the plate. If you can't read the plate, Frigate certainly won't be able to. This may require changing video size, quality, or frame rate settings on your camera, depending on your scene and how fast the vehicles are traveling. - The plate is large enough in the image (try adjusting `min_area`) or increasing the resolution of your camera's stream. - A `car` is detected first, as LPR only runs on recognized vehicles. diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index 76fcf6826..42809739a 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -183,32 +183,46 @@ The default dashboard ("All Cameras") will always use Smart Streaming and the fi ::: +### Disabling cameras + +Cameras can be temporarily disabled through the Frigate UI and through [MQTT](/integrations/mqtt#frigatecamera_nameenabledset) to conserve system resources. When disabled, Frigate's ffmpeg processes are terminated — recording stops, object detection is paused, and the Live dashboard displays a blank image with a disabled message. Review items, tracked objects, and historical footage for disabled cameras can still be accessed via the UI. + +For restreamed cameras, go2rtc remains active but does not use system resources for decoding or processing unless there are active external consumers (such as the Advanced Camera Card in Home Assistant using a go2rtc source). + +Note that disabling a camera through the config file (`enabled: False`) removes all related UI elements, including historical footage access. To retain access while disabling the camera, keep it enabled in the config and use the UI or MQTT to disable it temporarily. + ## Live view FAQ -1. Why don't I have audio in my Live view? +1. **Why don't I have audio in my Live view?** + You must use go2rtc to hear audio in your live streams. If you have go2rtc already configured, you need to ensure your camera is sending PCMA/PCMU or AAC audio. If you can't change your camera's audio codec, you need to [transcode the audio](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#source-ffmpeg) using go2rtc. Note that the low bandwidth mode player is a video-only stream. You should not expect to hear audio when in low bandwidth mode, even if you've set up go2rtc. -2. Frigate shows that my live stream is in "low bandwidth mode". What does this mean? +2. **Frigate shows that my live stream is in "low bandwidth mode". What does this mean?** + Frigate intelligently selects the live streaming technology based on a number of factors (user-selected modes like two-way talk, camera settings, browser capabilities, available bandwidth) and prioritizes showing an actual up-to-date live view of your camera's stream as quickly as possible. When you have go2rtc configured, Live view initially attempts to load and play back your stream with a clearer, fluent stream technology (MSE). An initial timeout, a low bandwidth condition that would cause buffering of the stream, or decoding errors in the stream will cause Frigate to switch to the stream defined by the `detect` role, using the jsmpeg format. This is what the UI labels as "low bandwidth mode". On Live dashboards, the mode will automatically reset when smart streaming is configured and activity stops. You can also try using the _Reset_ button to force a reload of your stream. If you are still experiencing Frigate falling back to low bandwidth mode, you may need to adjust your camera's settings per the recommendations above or ensure you have enough bandwidth available. -3. It doesn't seem like my cameras are streaming on the Live dashboard. Why? +3. **It doesn't seem like my cameras are streaming on the Live dashboard. Why?** + On the default Live dashboard ("All Cameras"), your camera images will update once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity is detected, cameras seamlessly switch to a full-resolution live stream. If you want to customize this behavior, use a camera group. -4. I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it? +4. **I see a strange diagonal line on my live view, but my recordings look fine. How can I fix it?** + This is caused by incorrect dimensions set in your detect width or height (or incorrectly auto-detected), causing the jsmpeg player's rendering engine to display a slightly distorted image. You should enlarge the width and height of your `detect` resolution up to a standard aspect ratio (example: 640x352 becomes 640x360, and 800x443 becomes 800x450, 2688x1520 becomes 2688x1512, etc). If changing the resolution to match a standard (4:3, 16:9, or 32:9, etc) aspect ratio does not solve the issue, you can enable "compatibility mode" in your camera group dashboard's stream settings. Depending on your browser and device, more than a few cameras in compatibility mode may not be supported, so only use this option if changing your `detect` width and height fails to resolve the color artifacts and diagonal line. -5. How does "smart streaming" work? +5. **How does "smart streaming" work?** + Because a static image of a scene looks exactly the same as a live stream with no motion or activity, smart streaming updates your camera images once per minute when no detectable activity is occurring to conserve bandwidth and resources. As soon as any activity (motion or object/audio detection) occurs, cameras seamlessly switch to a live stream. This static image is pulled from the stream defined in your config with the `detect` role. When activity is detected, images from the `detect` stream immediately begin updating at ~5 frames per second so you can see the activity until the live player is loaded and begins playing. This usually only takes a second or two. If the live player times out, buffers, or has streaming errors, the jsmpeg player is loaded and plays a video-only stream from the `detect` role. When activity ends, the players are destroyed and a static image is displayed until activity is detected again, and the process repeats. This is Frigate's default and recommended setting because it results in a significant bandwidth savings, especially for high resolution cameras. -6. I have unmuted some cameras on my dashboard, but I do not hear sound. Why? +6. **I have unmuted some cameras on my dashboard, but I do not hear sound. Why?** + If your camera is streaming (as indicated by a red dot in the upper right, or if it has been set to continuous streaming mode), your browser may be blocking audio until you interact with the page. This is an intentional browser limitation. See [this article](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide#autoplay_availability). Many browsers have a whitelist feature to change this behavior. diff --git a/docs/docs/configuration/record.md b/docs/docs/configuration/record.md index fd7de42d0..f84d84cee 100644 --- a/docs/docs/configuration/record.md +++ b/docs/docs/configuration/record.md @@ -183,6 +183,8 @@ record: sync_recordings: True ``` +This feature is meant to fix variations in files, not completely delete entries in the database. If you delete all of your media, don't use `sync_recordings`, just stop Frigate, delete the `frigate.db` database, and restart. + :::warning The sync operation uses considerable CPU resources and in most cases is not needed, only enable when necessary. From ad0e89e147de72f60569a5b005d7afad2032d361 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 5 Mar 2025 07:30:23 -0600 Subject: [PATCH 26/82] Ensure disabling a camera also disables audio detection (#16961) * Ensure disabling a camera also disables audio detection * fix enabled state * fix path --- frigate/events/audio.py | 56 ++++++++++++++++++++++++++++++++++++++++- frigate/video.py | 7 +++--- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/frigate/events/audio.py b/frigate/events/audio.py index 7675f821b..505802b8c 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -135,8 +135,13 @@ class AudioEventMaintainer(threading.Thread): # create communication for audio detections self.requestor = InterProcessRequestor() self.config_subscriber = ConfigSubscriber(f"config/audio/{camera.name}") + self.enabled_subscriber = ConfigSubscriber( + f"config/enabled/{camera.name}", True + ) self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio) + self.was_enabled = camera.enabled + def detect_audio(self, audio) -> None: if not self.config.audio.enabled or self.stop_event.is_set(): return @@ -248,6 +253,23 @@ class AudioEventMaintainer(threading.Thread): f"Failed to end audio event {detection['id']} with status code {resp.status_code}" ) + def expire_all_detections(self) -> None: + """Immediately end all current detections""" + now = datetime.datetime.now().timestamp() + for label, detection in list(self.detections.items()): + if detection: + self.requestor.send_data(f"{self.config.name}/audio/{label}", "OFF") + resp = requests.put( + f"{FRIGATE_LOCALHOST}/api/events/{detection['id']}/end", + json={"end_time": now}, + ) + if resp.status_code == 200: + self.detections[label] = None + else: + self.logger.warning( + f"Failed to end audio event {detection['id']} with status code {resp.status_code}" + ) + def start_or_restart_ffmpeg(self) -> None: self.audio_listener = start_or_restart_ffmpeg( self.ffmpeg_cmd, @@ -283,10 +305,41 @@ class AudioEventMaintainer(threading.Thread): self.logger.error(f"Error reading audio data from ffmpeg process: {e}") log_and_restart() + def _update_enabled_state(self) -> bool: + """Fetch the latest config and update enabled state.""" + _, config_data = self.enabled_subscriber.check_for_update() + if config_data: + self.config.enabled = config_data.enabled + return config_data.enabled + + return self.config.enabled + def run(self) -> None: - self.start_or_restart_ffmpeg() + if self._update_enabled_state(): + self.start_or_restart_ffmpeg() while not self.stop_event.is_set(): + enabled = self._update_enabled_state() + if enabled != self.was_enabled: + if enabled: + self.logger.debug( + f"Enabling audio detections for {self.config.name}" + ) + self.start_or_restart_ffmpeg() + else: + self.logger.debug( + f"Disabling audio detections for {self.config.name}, ending events" + ) + self.expire_all_detections() + stop_ffmpeg(self.audio_listener, self.logger) + self.audio_listener = None + self.was_enabled = enabled + continue + + if not enabled: + time.sleep(0.1) + continue + # check if there is an updated config ( updated_topic, @@ -302,6 +355,7 @@ class AudioEventMaintainer(threading.Thread): self.logpipe.close() self.requestor.stop() self.config_subscriber.stop() + self.enabled_subscriber.stop() self.detection_publisher.stop() diff --git a/frigate/video.py b/frigate/video.py index 69f6c1bfa..89543e21a 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -197,9 +197,10 @@ class CameraWatchdog(threading.Thread): """Fetch the latest config and update enabled state.""" _, config_data = self.config_subscriber.check_for_update() if config_data: - enabled = config_data.enabled - return enabled - return self.was_enabled if self.was_enabled is not None else self.config.enabled + self.config.enabled = config_data.enabled + return config_data.enabled + + return self.config.enabled def run(self): if self._update_enabled_state(): From 73c2c34127791221107480690a10806c3deb2bab Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 5 Mar 2025 07:07:48 -0700 Subject: [PATCH 27/82] Fix previews failing when disabled (#16962) * Fix previews failing when offline * Simplify frame cache handling --- frigate/output/output.py | 43 +++++++++++++++++------------ frigate/output/preview.py | 58 ++++++++++++++++++++++----------------- 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/frigate/output/output.py b/frigate/output/output.py index e0e64e298..30900a5ab 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -41,22 +41,30 @@ def check_disabled_camera_update( has_enabled_camera = False for camera, last_update in write_times.items(): + offline_time = now - last_update + if config.cameras[camera].enabled: has_enabled_camera = True + else: + # flag camera as offline when it is disabled + previews[camera].flag_offline(now) - if now - last_update > 1: - # last camera update was more than one second ago - # need to send empty data to updaters because current + if offline_time > 1: + # last camera update was more than 1 second ago + # need to send empty data to birdseye because current # frame is now out of date - frame = get_blank_yuv_frame( - config.cameras[camera].detect.width, - config.cameras[camera].detect.height, - ) - - if birdseye: - birdseye.write_data(camera, [], [], now, frame) - - previews[camera].write_data([], [], now, frame) + if birdseye and offline_time < 10: + # we only need to send blank frames to birdseye at the beginning of a camera being offline + birdseye.write_data( + camera, + [], + [], + now, + get_blank_yuv_frame( + config.cameras[camera].detect.width, + config.cameras[camera].detect.height, + ), + ) if not has_enabled_camera and birdseye: birdseye.all_cameras_disabled() @@ -170,6 +178,12 @@ def output_frames( else: failed_frame_requests[camera] = 0 + # send frames for low fps recording + preview_recorders[camera].write_data( + current_tracked_objects, motion_boxes, frame_time, frame + ) + preview_write_times[camera] = frame_time + # send camera frame to ffmpeg process if websockets are connected if any( ws.environ["PATH_INFO"].endswith(camera) for ws in websocket_server.manager @@ -193,11 +207,6 @@ def output_frames( frame, ) - # send frames for low fps recording - preview_recorders[camera].write_data( - current_tracked_objects, motion_boxes, frame_time, frame - ) - preview_write_times[camera] = frame_time frame_manager.close(frame_name) move_preview_frames("clips") diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 4f8796d39..247886bfd 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -23,7 +23,7 @@ from frigate.ffmpeg_presets import ( ) from frigate.models import Previews from frigate.object_processing import TrackedObject -from frigate.util.image import copy_yuv_to_position, get_yuv_crop +from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop logger = logging.getLogger(__name__) @@ -153,6 +153,7 @@ class PreviewRecorder: self.config = config self.start_time = 0 self.last_output_time = 0 + self.offline = False self.output_frames = [] if config.detect.width > config.detect.height: @@ -241,6 +242,17 @@ class PreviewRecorder: self.last_output_time = ts self.output_frames.append(ts) + def reset_frame_cache(self, frame_time: float) -> None: + self.segment_end = ( + (datetime.datetime.now() + datetime.timedelta(hours=1)) + .astimezone(datetime.timezone.utc) + .replace(minute=0, second=0, microsecond=0) + .timestamp() + ) + self.start_time = frame_time + self.last_output_time = frame_time + self.output_frames: list[float] = [] + def should_write_frame( self, current_tracked_objects: list[dict[str, any]], @@ -307,7 +319,9 @@ class PreviewRecorder: motion_boxes: list[list[int]], frame_time: float, frame: np.ndarray, - ) -> bool: + ) -> None: + self.offline = False + # check for updated record config _, updated_record_config = self.config_subscriber.check_for_update() @@ -319,7 +333,7 @@ class PreviewRecorder: self.start_time = frame_time self.output_frames.append(frame_time) self.write_frame_to_cache(frame_time, frame) - return False + return # check if PREVIEW clip should be generated and cached frames reset if frame_time >= self.segment_end: @@ -340,32 +354,35 @@ class PreviewRecorder: f"Not saving preview for {self.config.name} because there are no saved frames." ) - # reset frame cache - self.segment_end = ( - (datetime.datetime.now() + datetime.timedelta(hours=1)) - .astimezone(datetime.timezone.utc) - .replace(minute=0, second=0, microsecond=0) - .timestamp() - ) - self.start_time = frame_time - self.last_output_time = frame_time - self.output_frames: list[float] = [] + self.reset_frame_cache(frame_time) # include first frame to ensure consistent duration if self.config.record.enabled: self.output_frames.append(frame_time) self.write_frame_to_cache(frame_time, frame) - return True + return elif self.should_write_frame(current_tracked_objects, motion_boxes, frame_time): self.output_frames.append(frame_time) self.write_frame_to_cache(frame_time, frame) - return False + return def flag_offline(self, frame_time: float) -> None: + if not self.offline: + self.write_frame_to_cache( + frame_time, + get_blank_yuv_frame( + self.config.detect.width, self.config.detect.height + ), + ) + self.offline = True + # check if PREVIEW clip should be generated and cached frames reset if frame_time >= self.segment_end: if len(self.output_frames) == 0: + # camera has been offline for entire hour + # we have no preview to create + self.reset_frame_cache(frame_time) return old_frame_path = get_cache_image_name( @@ -382,16 +399,7 @@ class PreviewRecorder: self.requestor, ).start() - # reset frame cache - self.segment_end = ( - (datetime.datetime.now() + datetime.timedelta(hours=1)) - .astimezone(datetime.timezone.utc) - .replace(minute=0, second=0, microsecond=0) - .timestamp() - ) - self.start_time = frame_time - self.last_output_time = frame_time - self.output_frames = [] + self.reset_frame_cache(frame_time) def stop(self) -> None: self.requestor.stop() From 66d5f4f3b8662aef71229c73fd768d19d613c7b8 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 6 Mar 2025 06:59:35 -0700 Subject: [PATCH 28/82] Refactor enabled camera listeners (#16979) * Monitor if camera is disabled for review items * Simplify multi camera disabled check * Cleanup birdseye config handling * Cleanup * Remove old listeners --- frigate/object_processing.py | 44 +++++++++++++++------------ frigate/output/birdseye.py | 58 ++++++++++++++++-------------------- frigate/review/maintainer.py | 23 ++++++++++++-- 3 files changed, 71 insertions(+), 54 deletions(-) diff --git a/frigate/object_processing.py b/frigate/object_processing.py index a7a2fb066..8faf91cb5 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -440,10 +440,7 @@ class TrackedObjectProcessor(threading.Thread): self.last_motion_detected: dict[str, float] = {} self.ptz_autotracker_thread = ptz_autotracker_thread - self.enabled_subscribers = { - camera: ConfigSubscriber(f"config/enabled/{camera}", True) - for camera in config.cameras.keys() - } + self.config_enabled_subscriber = ConfigSubscriber("config/enabled/") self.requestor = InterProcessRequestor() self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) @@ -705,24 +702,34 @@ class TrackedObjectProcessor(threading.Thread): {"enabled": False, "motion": 0, "objects": []}, ) - def _get_enabled_state(self, camera: str) -> bool: - _, config_data = self.enabled_subscribers[camera].check_for_update() - - if config_data: - self.config.cameras[camera].enabled = config_data.enabled - - if self.camera_states[camera].prev_enabled is None: - self.camera_states[camera].prev_enabled = config_data.enabled - - return self.config.cameras[camera].enabled - def run(self): while not self.stop_event.is_set(): + # check for config updates + while True: + ( + updated_enabled_topic, + updated_enabled_config, + ) = self.config_enabled_subscriber.check_for_update() + + if not updated_enabled_topic: + break + + camera_name = updated_enabled_topic.rpartition("/")[-1] + self.config.cameras[ + camera_name + ].enabled = updated_enabled_config.enabled + + if self.camera_states[camera_name].prev_enabled is None: + self.camera_states[ + camera_name + ].prev_enabled = updated_enabled_config.enabled + + # manage camera disabled state for camera, config in self.config.cameras.items(): if not config.enabled_in_config: continue - current_enabled = self._get_enabled_state(camera) + current_enabled = config.enabled camera_state = self.camera_states[camera] if camera_state.prev_enabled and not current_enabled: @@ -746,7 +753,7 @@ class TrackedObjectProcessor(threading.Thread): except queue.Empty: continue - if not self._get_enabled_state(camera): + if not self.config.cameras[camera].enabled: logger.debug(f"Camera {camera} disabled, skipping update") continue @@ -792,7 +799,6 @@ class TrackedObjectProcessor(threading.Thread): self.detection_publisher.stop() self.event_sender.stop() self.event_end_subscriber.stop() - for subscriber in self.enabled_subscribers.values(): - subscriber.stop() + self.config_enabled_subscriber.stop() logger.info("Exiting object processor...") diff --git a/frigate/output/birdseye.py b/frigate/output/birdseye.py index cd4aa26ec..9bbd3abee 100644 --- a/frigate/output/birdseye.py +++ b/frigate/output/birdseye.py @@ -281,12 +281,6 @@ class BirdsEyeFrameManager: self.stop_event = stop_event self.inactivity_threshold = config.birdseye.inactivity_threshold - self.enabled_subscribers = { - cam: ConfigSubscriber(f"config/enabled/{cam}", True) - for cam in config.cameras.keys() - if config.cameras[cam].enabled_in_config - } - if config.birdseye.layout.max_cameras: self.last_refresh_time = 0 @@ -387,16 +381,6 @@ class BirdsEyeFrameManager: if mode == BirdseyeModeEnum.objects and object_box_count > 0: return True - def _get_enabled_state(self, camera: str) -> bool: - """Fetch the latest enabled state for a camera from ZMQ.""" - _, config_data = self.enabled_subscribers[camera].check_for_update() - - if config_data: - self.config.cameras[camera].enabled = config_data.enabled - return config_data.enabled - - return self.config.cameras[camera].enabled - def update_frame(self, frame: Optional[np.ndarray] = None) -> bool: """ Update birdseye, optionally with a new frame. @@ -410,7 +394,7 @@ class BirdsEyeFrameManager: for cam, cam_data in self.cameras.items() if self.config.cameras[cam].birdseye.enabled and self.config.cameras[cam].enabled_in_config - and self._get_enabled_state(cam) + and self.config.cameras[cam].enabled and cam_data["last_active_frame"] > 0 and cam_data["current_frame_time"] - cam_data["last_active_frame"] < self.inactivity_threshold @@ -706,11 +690,11 @@ class BirdsEyeFrameManager: frame: np.ndarray, ) -> bool: # don't process if birdseye is disabled for this camera - camera_config = self.config.cameras[camera].birdseye + camera_config = self.config.cameras[camera] force_update = False # disabling birdseye is a little tricky - if not self._get_enabled_state(camera): + if not camera_config.birdseye.enabled or not camera_config.enabled: # if we've rendered a frame (we have a value for last_active_frame) # then we need to set it to zero if self.cameras[camera]["last_active_frame"] > 0: @@ -722,7 +706,7 @@ class BirdsEyeFrameManager: # update the last active frame for the camera self.cameras[camera]["current_frame"] = frame.copy() self.cameras[camera]["current_frame_time"] = frame_time - if self.camera_active(camera_config.mode, object_count, motion_count): + if self.camera_active(camera_config.birdseye.mode, object_count, motion_count): self.cameras[camera]["last_active_frame"] = frame_time now = datetime.datetime.now().timestamp() @@ -745,11 +729,6 @@ class BirdsEyeFrameManager: return True return False - def stop(self): - """Clean up subscribers when stopping.""" - for subscriber in self.enabled_subscribers.values(): - subscriber.stop() - class Birdseye: def __init__( @@ -775,7 +754,8 @@ class Birdseye: "birdseye", self.converter, websocket_server, stop_event ) self.birdseye_manager = BirdsEyeFrameManager(config, stop_event) - self.config_subscriber = ConfigSubscriber("config/birdseye/") + self.config_enabled_subscriber = ConfigSubscriber("config/enabled/") + self.birdseye_subscriber = ConfigSubscriber("config/birdseye/") self.frame_manager = SharedMemoryFrameManager() self.stop_event = stop_event @@ -815,15 +795,27 @@ class Birdseye: # check if there is an updated config while True: ( - updated_topic, + updated_birdseye_topic, updated_birdseye_config, - ) = self.config_subscriber.check_for_update() + ) = self.birdseye_subscriber.check_for_update() - if not updated_topic: + ( + updated_enabled_topic, + updated_enabled_config, + ) = self.config_enabled_subscriber.check_for_update() + + if not updated_birdseye_topic and not updated_enabled_topic: break - camera_name = updated_topic.rpartition("/")[-1] - self.config.cameras[camera_name].birdseye = updated_birdseye_config + if updated_birdseye_config: + camera_name = updated_birdseye_topic.rpartition("/")[-1] + self.config.cameras[camera_name].birdseye = updated_birdseye_config + + if updated_enabled_config: + camera_name = updated_enabled_topic.rpartition("/")[-1] + self.config.cameras[ + camera_name + ].enabled = updated_enabled_config.enabled if self.birdseye_manager.update( camera, @@ -835,7 +827,7 @@ class Birdseye: self.__send_new_frame() def stop(self) -> None: - self.config_subscriber.stop() - self.birdseye_manager.stop() + self.birdseye_subscriber.stop() + self.config_enabled_subscriber.stop() self.converter.join() self.broadcaster.join() diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 158bc3ac4..1c015d217 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -150,6 +150,7 @@ class ReviewSegmentMaintainer(threading.Thread): self.requestor = InterProcessRequestor() self.record_config_subscriber = ConfigSubscriber("config/record/") self.review_config_subscriber = ConfigSubscriber("config/review/") + self.enabled_config_subscriber = ConfigSubscriber("config/enabled/") self.detection_subscriber = DetectionSubscriber(DetectionTypeEnum.all) # manual events @@ -450,7 +451,16 @@ class ReviewSegmentMaintainer(threading.Thread): updated_review_config, ) = self.review_config_subscriber.check_for_update() - if not updated_record_topic and not updated_review_topic: + ( + updated_enabled_topic, + updated_enabled_config, + ) = self.enabled_config_subscriber.check_for_update() + + if ( + not updated_record_topic + and not updated_review_topic + and not updated_enabled_topic + ): break if updated_record_topic: @@ -461,6 +471,12 @@ class ReviewSegmentMaintainer(threading.Thread): camera_name = updated_review_topic.rpartition("/")[-1] self.config.cameras[camera_name].review = updated_review_config + if updated_enabled_config: + camera_name = updated_enabled_topic.rpartition("/")[-1] + self.config.cameras[ + camera_name + ].enabled = updated_enabled_config.enabled + (topic, data) = self.detection_subscriber.check_for_update(timeout=1) if not topic: @@ -494,7 +510,10 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment = self.active_review_segments.get(camera) - if not self.config.cameras[camera].record.enabled: + if ( + not self.config.cameras[camera].enabled + or not self.config.cameras[camera].record.enabled + ): if current_segment: self.end_segment(camera) continue From 30acd26898dea461cdb2b43394079eb28733efeb Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 6 Mar 2025 07:00:29 -0700 Subject: [PATCH 29/82] Disable detection by default (#16980) * Enable detection by default * Migrate config to have detect enabled if it is not --- docs/docs/configuration/reference.md | 4 ++-- docs/docs/guides/getting_started.md | 4 +--- frigate/config/camera/detect.py | 2 +- frigate/util/config.py | 6 ++++++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 47d1ccb07..37884259a 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -255,6 +255,8 @@ ffmpeg: # Optional: Detect configuration # NOTE: Can be overridden at the camera level detect: + # Optional: enables detection for the camera (default: shown below) + enabled: False # Optional: width of the frame for the input with the detect role (default: use native stream resolution) width: 1280 # Optional: height of the frame for the input with the detect role (default: use native stream resolution) @@ -262,8 +264,6 @@ detect: # Optional: desired fps for your camera for the input with the detect role (default: shown below) # NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera. fps: 5 - # Optional: enables detection for the camera (default: True) - enabled: True # Optional: Number of consecutive detection hits required for an object to be initialized in the tracker. (default: 1/2 the frame rate) min_initialized: 2 # Optional: Number of frames without a detection before Frigate considers an object to be gone. (default: 5x the frame rate) diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index ed2cfb4f4..6fe3a8e22 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -151,8 +151,6 @@ cameras: - path: rtsp://10.0.10.10:554/rtsp # <----- The stream you want to use for detection roles: - detect - detect: - enabled: False # <---- disable detection until you have a working camera feed ``` ### Step 2: Start Frigate @@ -307,7 +305,7 @@ By default, Frigate will retain video of all tracked objects for 10 days. The fu ### Step 7: Complete config -At this point you have a complete config with basic functionality. +At this point you have a complete config with basic functionality. - View [common configuration examples](../configuration/index.md#common-configuration-examples) for a list of common configuration examples. - View [full config reference](../configuration/reference.md) for a complete list of configuration options. diff --git a/frigate/config/camera/detect.py b/frigate/config/camera/detect.py index 273364e61..99e02c2c8 100644 --- a/frigate/config/camera/detect.py +++ b/frigate/config/camera/detect.py @@ -32,6 +32,7 @@ class StationaryConfig(FrigateBaseModel): class DetectConfig(FrigateBaseModel): + enabled: bool = Field(default=False, title="Detection Enabled.") height: Optional[int] = Field( default=None, title="Height of the stream for the detect role." ) @@ -41,7 +42,6 @@ class DetectConfig(FrigateBaseModel): fps: int = Field( default=5, title="Number of frames per second to process through detection." ) - enabled: bool = Field(default=True, title="Detection Enabled.") min_initialized: Optional[int] = Field( default=None, title="Minimum number of consecutive hits for an object to be initialized by the tracker.", diff --git a/frigate/util/config.py b/frigate/util/config.py index 1ed82f802..7bdc0c3d6 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -300,6 +300,12 @@ def migrate_016_0(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any] """Handle migrating frigate config to 0.16-0""" new_config = config.copy() + # migrate config that does not have detect -> enabled explicitly set to have it enabled + if new_config.get("detect", {}).get("enabled") is None: + detect_config = new_config.get("detect", {}) + detect_config["enabled"] = True + new_config["detect"] = detect_config + for name, camera in config.get("cameras", {}).items(): camera_config: dict[str, dict[str, any]] = camera.copy() From 433da8ffce844798a7adf1a22ba438d7b287089e Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 6 Mar 2025 09:50:37 -0700 Subject: [PATCH 30/82] Update web deps (#16983) * Update vite * Update LuIcons * Update radix packages * Fix other icons * Use correct node version * Remove superfluous web build on python tests * Move web build to test --- .github/workflows/pull_request.yml | 13 +- web/package-lock.json | 2446 ++++++++--------- web/package.json | 66 +- .../components/graph/CombinedStorageGraph.tsx | 4 +- .../indicators/activity-indicator.tsx | 4 +- web/src/components/menu/GeneralSettings.tsx | 4 +- .../components/menu/SearchResultActions.tsx | 5 +- .../overlay/detail/ObjectLifecycle.tsx | 4 +- web/src/views/explore/ExploreView.tsx | 4 +- .../settings/NotificationsSettingsView.tsx | 5 +- web/src/views/system/StorageMetrics.tsx | 4 +- 11 files changed, 1150 insertions(+), 1409 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 0d0492d0c..37f75bf85 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - uses: actions/setup-node@master with: - node-version: 16.x + node-version: 20.x - name: Install devcontainer cli run: npm install --global @devcontainers/cli - name: Build devcontainer @@ -64,6 +64,9 @@ jobs: node-version: 20.x - run: npm install working-directory: ./web + - name: Build web + run: npm run build + working-directory: ./web # - name: Test # run: npm run test # working-directory: ./web @@ -99,14 +102,6 @@ jobs: uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/setup-node@master - with: - node-version: 16.x - - run: npm install - working-directory: ./web - - name: Build web - run: npm run build - working-directory: ./web - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx diff --git a/web/package-lock.json b/web/package-lock.json index f2b186312..97a0d991b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,45 +8,45 @@ "name": "web-new", "version": "0.0.0", "dependencies": { - "@cycjimmy/jsmpeg-player": "^6.1.1", + "@cycjimmy/jsmpeg-player": "^6.1.2", "@hookform/resolvers": "^3.9.0", "@melloware/react-logviewer": "^6.1.2", - "@radix-ui/react-alert-dialog": "^1.1.2", - "@radix-ui/react-aspect-ratio": "^1.1.0", - "@radix-ui/react-checkbox": "^1.1.2", - "@radix-ui/react-context-menu": "^2.2.2", - "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-hover-card": "^1.1.2", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-popover": "^1.1.2", - "@radix-ui/react-radio-group": "^1.2.1", - "@radix-ui/react-scroll-area": "^1.2.0", - "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-aspect-ratio": "^1.1.2", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-switch": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.1", - "@radix-ui/react-toggle": "^1.1.0", - "@radix-ui/react-toggle-group": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.3", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", "apexcharts": "^3.52.0", "axios": "^1.7.7", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", "embla-carousel-react": "^8.2.0", "framer-motion": "^11.5.4", - "hls.js": "^1.5.17", + "hls.js": "^1.5.20", "idb-keyval": "^6.2.1", "immer": "^10.1.1", - "konva": "^9.3.16", + "konva": "^9.3.18", "lodash": "^4.17.21", - "lucide-react": "^0.407.0", - "monaco-yaml": "^5.2.2", + "lucide-react": "^0.477.0", + "monaco-yaml": "^5.3.1", "next-themes": "^0.3.0", "nosleep.js": "^0.12.0", "react": "^18.3.1", @@ -56,10 +56,10 @@ "react-dom": "^18.3.1", "react-grid-layout": "^1.5.0", "react-hook-form": "^7.52.1", - "react-icons": "^5.2.1", + "react-icons": "^5.5.0", "react-konva": "^18.2.10", "react-router-dom": "^6.26.0", - "react-swipeable": "^7.0.1", + "react-swipeable": "^7.0.2", "react-tracked": "^2.0.1", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", @@ -69,7 +69,7 @@ "sonner": "^1.5.0", "sort-by": "^1.2.0", "strftime": "^0.10.3", - "swr": "^2.2.5", + "swr": "^2.3.2", "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", @@ -91,8 +91,8 @@ "@types/strftime": "^0.9.8", "@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/parser": "^7.5.0", - "@vitejs/plugin-react-swc": "^3.7.1", - "@vitest/coverage-v8": "^2.0.5", + "@vitejs/plugin-react-swc": "^3.8.0", + "@vitest/coverage-v8": "^3.0.7", "autoprefixer": "^10.4.20", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -109,9 +109,9 @@ "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.5", "tailwindcss": "^3.4.9", - "typescript": "^5.5.4", - "vite": "^5.4.0", - "vitest": "^2.0.5" + "typescript": "^5.8.2", + "vite": "^6.2.0", + "vitest": "^3.0.7" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -156,9 +156,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "dev": true, "license": "MIT", "engines": { @@ -166,9 +166,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "dev": true, "license": "MIT", "engines": { @@ -176,11 +176,14 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -200,33 +203,37 @@ } }, "node_modules/@babel/types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", - "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/@bundled-es-modules/cookie": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.0.tgz", - "integrity": "sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", "dev": true, + "license": "ISC", "dependencies": { - "cookie": "^0.5.0" + "cookie": "^0.7.2" } }, "node_modules/@bundled-es-modules/statuses": { @@ -250,15 +257,15 @@ } }, "node_modules/@cycjimmy/jsmpeg-player": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.1.tgz", - "integrity": "sha512-YqT7U/3LxGu+6ikd+GGPe3rA2o6P4xrBHsWi/WRqv4n58h91fWDxS/3smneod+u6H2RnWlmXvZqx960dQ9T9gQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.2.tgz", + "integrity": "sha512-U9DBDe5fxHmbwQww9rFxMLNI2Wlg7DhPzI7AVFpq8GehiUP7+NwuMPXpP4zAd52sgkxtOqOeMjgE5g0ZLnQZ0w==", "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", "cpu": [ "ppc64" ], @@ -269,13 +276,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], @@ -286,13 +293,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], @@ -303,13 +310,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], @@ -320,13 +327,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], @@ -337,13 +344,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], @@ -354,13 +361,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], @@ -371,13 +378,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], @@ -388,13 +395,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], @@ -405,13 +412,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], @@ -422,13 +429,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], @@ -439,13 +446,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], @@ -456,13 +463,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], @@ -473,13 +480,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], @@ -490,13 +497,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], @@ -507,13 +514,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], @@ -524,13 +531,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], @@ -541,13 +548,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], @@ -558,13 +582,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], @@ -575,13 +616,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", "cpu": [ "x64" ], @@ -592,13 +633,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], @@ -609,13 +650,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], @@ -626,13 +667,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], @@ -643,7 +684,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -705,28 +746,28 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.4.tgz", - "integrity": "sha512-a4IowK4QkXl4SCWTGUR0INAfEOX3wtsYw3rKK5InQEHMGObkR8Xk44qYQD9P4r6HHw0iIfK6GUKECmY8sTkqRA==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", + "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.4" + "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.7.tgz", - "integrity": "sha512-wmVfPG5o2xnKDU4jx/m4w5qva9FWHcnZ8BvzEe90D/RpwsJaTAVYPEPdQ8sbr/N8zZTAHlZUTQdqg8ZUbzHmng==", + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", + "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.4" + "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.0.0" @@ -737,9 +778,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.4.tgz", - "integrity": "sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, "node_modules/@hookform/resolvers": { @@ -787,50 +828,81 @@ "license": "BSD-3-Clause" }, "node_modules/@inquirer/confirm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.0.0.tgz", - "integrity": "sha512-LHeuYP1D8NmQra1eR4UqvZMXwxEdDXyElJmmZfU44xdNLL6+GcQBS0uE16vyfZVjH8c22p9e+DStROfE/hyHrg==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", + "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/core": "^7.0.0", - "@inquirer/type": "^1.2.0" + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/core": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-7.0.0.tgz", - "integrity": "sha512-g13W5yEt9r1sEVVriffJqQ8GWy94OnfxLCreNSOTw0HPVcszmc/If1KIf7YBmlwtX4klmvwpZHnQpl3N7VX2xA==", + "version": "10.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.7.tgz", + "integrity": "sha512-AA9CQhlrt6ZgiSy6qoAigiA1izOa751ugX6ioSjqgJ+/Gd+tEN/TORk5sUYNjXuHWfW0r1n/a6ak4u/NqHHrtA==", "dev": true, + "license": "MIT", "dependencies": { - "@inquirer/type": "^1.2.0", - "@types/mute-stream": "^0.0.4", - "@types/node": "^20.11.16", - "@types/wrap-ansi": "^3.0.0", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.4", "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "cli-spinners": "^2.9.2", "cli-width": "^4.1.0", - "figures": "^3.2.0", - "mute-stream": "^1.0.0", - "run-async": "^3.0.0", + "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0" + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.10.tgz", + "integrity": "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==", + "dev": true, + "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@inquirer/type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.2.0.tgz", - "integrity": "sha512-/vvkUkYhrjbm+RolU7V1aUFDydZVKNKqKHR5TsE+j5DXgXFwrsOPcoGUJ02K0O7q7O53CU2DOTMYCHeGZ25WHA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.4.tgz", + "integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@isaacs/cliui": { @@ -990,9 +1062,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -1020,16 +1093,17 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz", - "integrity": "sha512-3rDakgJZ77+RiQUuSK69t1F0m8BQKA8Vh5DCS5V0DWvNY67zob2JhhQrhCO0AKLGINTRSFd1tBaHcJTkhefoSw==", + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", "dev": true, + "license": "MIT", "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", - "outvariant": "^1.2.1", + "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" }, "engines": { @@ -1072,13 +1146,15 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@open-draft/logger": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", "dev": true, + "license": "MIT", "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" @@ -1088,7 +1164,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -1128,23 +1205,23 @@ "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", - "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", "license": "MIT" }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz", - "integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz", + "integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dialog": "1.1.2", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -1161,46 +1238,13 @@ } } }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", - "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -1218,12 +1262,12 @@ } }, "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.0.tgz", - "integrity": "sha512-dP87DM/Y7jFlPgUZTlhx6FF5CEzOiaxp2rBCKlaXlpH5Ip/9Fg5zZ9lDOQ5o/MOfUlf36eak14zoWYpgcgGoOg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.2.tgz", + "integrity": "sha512-TaJxYoCpxJ7vfEkv2PTNox/6zzmpKXT6ewvCuf2tTOIVN45/Jahhlld29Yw4pciOXS2Xq91/rSGEdmEnUWZCqA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -1241,16 +1285,16 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.2.tgz", - "integrity": "sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz", + "integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" @@ -1270,31 +1314,16 @@ } } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", - "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -1311,28 +1340,10 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1345,9 +1356,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1360,15 +1371,15 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.2.tgz", - "integrity": "sha512-99EatSTpW+hRYHt7m8wdDlLtkmTovEe8Z/hnxUPV+SKuuNL5HWNhQI4QSdjZqNSgXHay2z4M3Dym73j9p2Gx5Q==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.6.tgz", + "integrity": "sha512-aUP99QZ3VU84NPsHeaFt4cQUNgJqFsLLOt/RbbWXszZ6MP0DpDyjkFZORr4RpAEx3sUBk+Kc8h13yGtC5Qw8dg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", + "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-menu": "2.1.2", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, @@ -1387,41 +1398,26 @@ } } }, - "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz", - "integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", + "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -1438,39 +1434,6 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -1487,14 +1450,14 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", - "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, @@ -1514,17 +1477,17 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz", - "integrity": "sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", + "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-menu": "2.1.2", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { @@ -1542,21 +1505,6 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", @@ -1573,13 +1521,13 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", - "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { @@ -1598,19 +1546,19 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.2.tgz", - "integrity": "sha512-Y5w0qGhysvmqsIy6nQxaPa6mXNKznfoGjOfBgzOjocLxr2XlSjqBMYQQL+FfyogsMuX+m8cZyQGYhJxvxUzO4w==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz", + "integrity": "sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { @@ -1628,21 +1576,6 @@ } } }, - "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -1662,12 +1595,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", - "integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz", + "integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -1685,29 +1618,29 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz", - "integrity": "sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", + "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -1724,60 +1657,27 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", - "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -1794,50 +1694,17 @@ } } }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popper": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", - "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", @@ -1860,12 +1727,12 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", - "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { @@ -1884,12 +1751,12 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", - "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { @@ -1908,12 +1775,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.1.0" + "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -1930,37 +1797,19 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz", - "integrity": "sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz", + "integrity": "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" @@ -1980,34 +1829,19 @@ } } }, - "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", - "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, @@ -2027,18 +1861,18 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.0.tgz", - "integrity": "sha512-q2jMBdsJ9zB7QG6ngQNzNwlvxLQqONyL58QbEGwuyRZZb/ARQwk3uQVbCF7GvQVOtV6EU/pDxAw3zRzJZI3rpQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.3.tgz", + "integrity": "sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.0", - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, @@ -2057,48 +1891,33 @@ } } }, - "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-select": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz", - "integrity": "sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", + "integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.0", - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" + "@radix-ui/react-visually-hidden": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", @@ -2115,46 +1934,13 @@ } } }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", - "integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", + "integrity": "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -2172,18 +1958,18 @@ } }, "node_modules/@radix-ui/react-slider": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.1.tgz", - "integrity": "sha512-bEzQoDW0XP+h/oGbutF5VMWJPAl/UU8IJjr7h02SOHDIIIxq+cep8nItVNoBV+OMmahCdqdF38FTpmXoqQUGvw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz", + "integrity": "sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.0", - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", @@ -2204,21 +1990,6 @@ } } }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", @@ -2237,31 +2008,16 @@ } } }, - "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", - "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-switch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz", - "integrity": "sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz", + "integrity": "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" @@ -2281,34 +2037,19 @@ } } }, - "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", - "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", + "integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", + "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { @@ -2326,29 +2067,14 @@ } } }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", - "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.2.tgz", + "integrity": "sha512-lntKchNWx3aCHuWKiDY+8WudiegQvBpDRAYL8dKLRvKEH8VOpl0XX6SSU/bUBqIRJbcTy4+MW06Wv8vgp10rzQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { @@ -2367,17 +2093,17 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", - "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.2.tgz", + "integrity": "sha512-JBm6s6aVG/nwuY5eadhU2zDi/IwYS0sDM5ZWb4nymv/hn3hZdkw+gENn0LP4iY1yCd7+bgJaCwueMYJIU3vk4A==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-toggle": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-toggle": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { @@ -2396,23 +2122,23 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.3.tgz", - "integrity": "sha512-Z4w1FIS0BqVFI2c1jZvb/uDVJijJjJ2ZMuPV81oVgTZ7g3BZxobplnMVvXtFWgtozdvYJ+MFWtwkM5S2HnAong==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", + "integrity": "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" + "@radix-ui/react-visually-hidden": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -2429,39 +2155,6 @@ } } }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -2580,12 +2273,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", - "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.0.0" + "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", @@ -2618,169 +2311,266 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz", + "integrity": "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.9.tgz", + "integrity": "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz", + "integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz", + "integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.9.tgz", + "integrity": "sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.9.tgz", + "integrity": "sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.9.tgz", + "integrity": "sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.9.tgz", + "integrity": "sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz", + "integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz", + "integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.9.tgz", + "integrity": "sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.9.tgz", + "integrity": "sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.9.tgz", + "integrity": "sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.9.tgz", + "integrity": "sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz", + "integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz", + "integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz", + "integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.9.tgz", + "integrity": "sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz", + "integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -2793,15 +2583,15 @@ "dev": true }, "node_modules/@swc/core": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.39.tgz", - "integrity": "sha512-jns6VFeOT49uoTKLWIEfiQqJAlyqldNAt80kAr8f7a5YjX0zgnG3RBiLMpksx4Ka4SlK4O6TJ/lumIM3Trp82g==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.7.tgz", + "integrity": "sha512-ICuzjyfz8Hh3U16Mb21uCRJeJd/lUgV999GjgvPhJSISM1L8GDSB5/AMNcwuGs7gFywTKI4vAeeXWyCETUXHAg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.13" + "@swc/types": "^0.1.19" }, "engines": { "node": ">=10" @@ -2811,16 +2601,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.7.39", - "@swc/core-darwin-x64": "1.7.39", - "@swc/core-linux-arm-gnueabihf": "1.7.39", - "@swc/core-linux-arm64-gnu": "1.7.39", - "@swc/core-linux-arm64-musl": "1.7.39", - "@swc/core-linux-x64-gnu": "1.7.39", - "@swc/core-linux-x64-musl": "1.7.39", - "@swc/core-win32-arm64-msvc": "1.7.39", - "@swc/core-win32-ia32-msvc": "1.7.39", - "@swc/core-win32-x64-msvc": "1.7.39" + "@swc/core-darwin-arm64": "1.11.7", + "@swc/core-darwin-x64": "1.11.7", + "@swc/core-linux-arm-gnueabihf": "1.11.7", + "@swc/core-linux-arm64-gnu": "1.11.7", + "@swc/core-linux-arm64-musl": "1.11.7", + "@swc/core-linux-x64-gnu": "1.11.7", + "@swc/core-linux-x64-musl": "1.11.7", + "@swc/core-win32-arm64-msvc": "1.11.7", + "@swc/core-win32-ia32-msvc": "1.11.7", + "@swc/core-win32-x64-msvc": "1.11.7" }, "peerDependencies": { "@swc/helpers": "*" @@ -2832,9 +2622,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.39.tgz", - "integrity": "sha512-o2nbEL6scMBMCTvY9OnbyVXtepLuNbdblV9oNJEFia5v5eGj9WMrnRQiylH3Wp/G2NYkW7V1/ZVW+kfvIeYe9A==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.7.tgz", + "integrity": "sha512-3+LhCP2H50CLI6yv/lhOtoZ5B/hi7Q/23dye1KhbSDeDprLTm/KfLJh/iQqwaHUponf5m8C2U0y6DD+HGLz8Yw==", "cpu": [ "arm64" ], @@ -2849,9 +2639,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.39.tgz", - "integrity": "sha512-qMlv3XPgtPi/Fe11VhiPDHSLiYYk2dFYl747oGsHZPq+6tIdDQjIhijXPcsUHIXYDyG7lNpODPL8cP/X1sc9MA==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.7.tgz", + "integrity": "sha512-1diWpJqwX1XmOghf9ENFaeRaTtqLiqlZIW56RfOqmeZ7tPp3qS7VygWb9akptBsO5pEA5ZwNgSerD6AJlQcjAw==", "cpu": [ "x64" ], @@ -2866,9 +2656,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.39.tgz", - "integrity": "sha512-NP+JIkBs1ZKnpa3Lk2W1kBJMwHfNOxCUJXuTa2ckjFsuZ8OUu2gwdeLFkTHbR43dxGwH5UzSmuGocXeMowra/Q==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.7.tgz", + "integrity": "sha512-MV8+hLREf0NN23NuSKemsjFaWjl/HnqdOkE7uhXTnHzg8WTwp6ddVtU5Yriv15+d/ktfLWPVAOhLHQ4gzaoa8A==", "cpu": [ "arm" ], @@ -2883,9 +2673,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.39.tgz", - "integrity": "sha512-cPc+/HehyHyHcvAsk3ML/9wYcpWVIWax3YBaA+ScecJpSE04l/oBHPfdqKUPslqZ+Gcw0OWnIBGJT/fBZW2ayw==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.7.tgz", + "integrity": "sha512-5GNs8ZjHQy/UTSnzzn+gm1RCUpCYo43lsxYOl8mpcnZSfxkNFVpjfylBv0QuJ5qhdfZ2iU55+v4iJCwCMtw0nA==", "cpu": [ "arm64" ], @@ -2900,9 +2690,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.39.tgz", - "integrity": "sha512-8RxgBC6ubFem66bk9XJ0vclu3exJ6eD7x7CwDhp5AD/tulZslTYXM7oNPjEtje3xxabXuj/bEUMNvHZhQRFdqA==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.7.tgz", + "integrity": "sha512-cTydaYBwDbVV5CspwVcCp9IevYWpGD1cF5B5KlBdjmBzxxeWyTAJRtKzn8w5/UJe/MfdAptarpqMPIs2f33YEQ==", "cpu": [ "arm64" ], @@ -2917,9 +2707,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.39.tgz", - "integrity": "sha512-3gtCPEJuXLQEolo9xsXtuPDocmXQx12vewEyFFSMSjOfakuPOBmOQMa0sVL8Wwius8C1eZVeD1fgk0omMqeC+Q==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.7.tgz", + "integrity": "sha512-YAX2KfYPlbDsnZiVMI4ZwotF3VeURUrzD+emJgFf1g26F4eEmslldgnDrKybW7V+bObsH22cDqoy6jmQZgpuPQ==", "cpu": [ "x64" ], @@ -2934,9 +2724,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.39.tgz", - "integrity": "sha512-mg39pW5x/eqqpZDdtjZJxrUvQNSvJF4O8wCl37fbuFUqOtXs4TxsjZ0aolt876HXxxhsQl7rS+N4KioEMSgTZw==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.7.tgz", + "integrity": "sha512-mYT6FTDZyYx5pailc8xt6ClS2yjKmP8jNHxA9Ce3K21n5qkKilI5M2N7NShwXkd3Ksw3F29wKrg+wvEMXTRY/A==", "cpu": [ "x64" ], @@ -2951,9 +2741,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.39.tgz", - "integrity": "sha512-NZwuS0mNJowH3e9bMttr7B1fB8bW5svW/yyySigv9qmV5VcQRNz1kMlCvrCLYRsa93JnARuiaBI6FazSeG8mpA==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.7.tgz", + "integrity": "sha512-uLDQEcv0BHcepypstyxKkNsW6KfLyI5jVxTbcxka+B2UnMcFpvoR87nGt2JYW0grO2SNZPoFz+UnoKL9c6JxpA==", "cpu": [ "arm64" ], @@ -2968,9 +2758,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.39.tgz", - "integrity": "sha512-qFmvv5UExbJPXhhvCVDBnjK5Duqxr048dlVB6ZCgGzbRxuarOlawCzzLK4N172230pzlAWGLgn9CWl3+N6zfHA==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.7.tgz", + "integrity": "sha512-wiq5G3fRizdxAJVFcon7zpyfbfrb+YShuTy+TqJ4Nf5PC0ueMOXmsmeuyQGApn6dVWtGCyymYQYt77wHeQajdA==", "cpu": [ "ia32" ], @@ -2985,9 +2775,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.39.tgz", - "integrity": "sha512-o+5IMqgOtj9+BEOp16atTfBgCogVak9svhBpwsbcJQp67bQbxGYhAPPDW/hZ2rpSSF7UdzbY9wudoX9G4trcuQ==", + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.7.tgz", + "integrity": "sha512-/zQdqY4fHkSORxEJ2cKtRBOwglvf/8gs6Tl4Q6VMx2zFtFpIOwFQstfY5u8wBNN2Z+PkAzyUCPoi8/cQFK8HLQ==", "cpu": [ "x64" ], @@ -3009,9 +2799,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz", - "integrity": "sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==", + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz", + "integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3072,10 +2862,11 @@ "dev": true }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/lodash": { "version": "4.17.12", @@ -3084,15 +2875,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mute-stream": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", - "integrity": "sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "20.14.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", @@ -3182,12 +2964,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/wrap-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", - "integrity": "sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==", - "dev": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.12.0.tgz", @@ -3415,127 +3191,132 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react-swc": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.1.tgz", - "integrity": "sha512-vgWOY0i1EROUK0Ctg1hwhtC3SdcDjZcdit4Ups4aPkDcB1jYhmo+RMYWY87cmXMhvtD5uf8lV89j2w16vkdSVg==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.8.0.tgz", + "integrity": "sha512-T4sHPvS+DIqDP51ifPqa9XIRAz/kIvIi8oXcnOZZgHmMotgmmdxe/DD5tMFlt5nuIRzT0/QuiwmKlH0503Aapw==", "dev": true, "license": "MIT", "dependencies": { - "@swc/core": "^1.7.26" + "@swc/core": "^1.10.15" }, "peerDependencies": { - "vite": "^4 || ^5" + "vite": "^4 || ^5 || ^6" } }, "node_modules/@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.7.tgz", + "integrity": "sha512-Av8WgBJLTrfLOer0uy3CxjlVuWK4CzcLBndW1Nm2vI+3hZ2ozHututkfc7Blu1u6waeQ7J8gzPK/AsBRnWA5mQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "@bcoe/v8-coverage": "^1.0.2", + "debug": "^4.4.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", - "magicast": "^0.3.4", - "std-env": "^3.7.0", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.8.0", "test-exclude": "^7.0.1", - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.0.5" + "@vitest/browser": "3.0.7", + "vitest": "3.0.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.7.tgz", + "integrity": "sha512-QP25f+YJhzPfHrHfYHtvRn+uvkCFCqFtW9CktfBxmB+25QqWsx7VB2As6f4GmwllHLDhXNHvqedwhvMmSnNmjw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", - "chai": "^5.1.1", - "tinyrainbow": "^1.2.0" + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.7.tgz", + "integrity": "sha512-CiRY0BViD/V8uwuEzz9Yapyao+M9M008/9oMOSQydwbwb+CMokEq3XVaF3XK/VWaOK0Jm9z7ENhybg70Gtxsmg==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.7.tgz", + "integrity": "sha512-WeEl38Z0S2ZcuRTeyYqaZtm4e26tq6ZFqh5y8YD9YxfWuu0OFiGFUbnxNynwLjNRHPsXyee2M9tV7YxOTPZl2g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.0.5", - "pathe": "^1.1.2" + "@vitest/utils": "3.0.7", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.7.tgz", + "integrity": "sha512-eqTUryJWQN0Rtf5yqCGTQWsCFOQe4eNz5Twsu21xYEcnFJtMU5XvmG0vgebhdLlrHQTSq5p8vWHJIeJQV8ovsA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", - "pathe": "^1.1.2" + "@vitest/pretty-format": "3.0.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.7.tgz", + "integrity": "sha512-4T4WcsibB0B6hrKdAZTM37ekuyFZt2cGbEGd2+L0P8ov15J1/HUsUaqkXEQPNAWr4BtPPe1gI+FYfMHhEKfR8w==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^3.0.0" + "tinyspy": "^3.0.2" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.7.tgz", + "integrity": "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", - "loupe": "^3.1.1", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "3.0.7", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3603,6 +3384,7 @@ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -3618,6 +3400,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -3694,9 +3477,10 @@ "license": "Python-2.0" }, "node_modules/aria-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", - "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "license": "MIT", "dependencies": { "tslib": "^2.0.0" }, @@ -3939,9 +3723,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", - "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", "dev": true, "license": "MIT", "dependencies": { @@ -4019,34 +3803,15 @@ } }, "node_modules/class-variance-authority": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", - "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", "dependencies": { - "clsx": "2.0.0" + "clsx": "^2.1.1" }, "funding": { - "url": "https://joebell.co.uk" - } - }, - "node_modules/class-variance-authority/node_modules/clsx": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", - "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://polar.sh/cva" } }, "node_modules/cli-width": { @@ -4054,15 +3819,11 @@ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, + "license": "ISC", "engines": { "node": ">= 12" } }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4528,10 +4289,11 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -4615,13 +4377,13 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -4762,7 +4524,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "engines": { "node": ">=6" } @@ -4893,10 +4654,17 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4904,32 +4672,34 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/escalade": { @@ -5231,27 +5001,14 @@ "node": ">=0.10.0" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "node_modules/expect-type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.0.tgz", + "integrity": "sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==", "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, + "license": "Apache-2.0", "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "node": ">=12.0.0" } }, "node_modules/fake-indexeddb": { @@ -5329,30 +5086,6 @@ "reusify": "^1.0.4" } }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5537,16 +5270,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -5556,18 +5279,6 @@ "node": ">=6" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5683,9 +5394,9 @@ "dev": true }, "node_modules/hls.js": { - "version": "1.5.17", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.17.tgz", - "integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==", + "version": "1.5.20", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz", + "integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==", "license": "Apache-2.0" }, "node_modules/hotkeys-js": { @@ -5744,15 +5455,6 @@ "node": ">= 14" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/idb-keyval": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", @@ -5826,15 +5528,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5922,7 +5615,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-number": { "version": "7.0.0", @@ -6217,9 +5911,9 @@ } }, "node_modules/konva": { - "version": "9.3.16", - "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.16.tgz", - "integrity": "sha512-qa47cefGDDHzkToGRGDsy24f/Njrz7EHP56jQ8mlDcjAPO7vkfTDeoBDIfmF7PZtpfzDdooafQmEUJMDU2F7FQ==", + "version": "9.3.18", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.18.tgz", + "integrity": "sha512-ad5h0Y9phUrinBrKXyIISbURRHQO7Rx5cz7mAEEfdVCs45gDqRD8Y0I0nJRk8S6iqEbiRE87CEZu5GVSnU8oow==", "funding": [ { "type": "patreon", @@ -6300,14 +5994,11 @@ } }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-cache": { "version": "10.4.3", @@ -6317,33 +6008,33 @@ "license": "ISC" }, "node_modules/lucide-react": { - "version": "0.407.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.407.0.tgz", - "integrity": "sha512-+dRIu9Sry+E8wPF9+sY5eKld2omrU4X5IKXxrgqBt+o11IIHVU0QOfNoVWFuj0ZRDrxr4Wci26o2mKZqLGE0lA==", + "version": "0.477.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.477.0.tgz", + "integrity": "sha512-yCf7aYxerFZAbd8jHJxjwe1j7jEMPptjnaOqdYeirFnEy85cNR3/L+o0I875CYFYya+eEVzZSbNuRk8BZPDpVw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/magic-string": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", - "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/magicast": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.4.tgz", - "integrity": "sha512-TyDF/Pn36bBji9rWKHlZe+PZb6Mx5V8IHCSxk7X4aljM4e/vyDvZZYwHewdVaqiA0nb3ghfHU/6AUpDxWoER2Q==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.24.4", - "@babel/types": "^7.24.0", + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, @@ -6523,9 +6214,9 @@ } }, "node_modules/monaco-yaml": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.2.2.tgz", - "integrity": "sha512-NWO/UhtJATlIsqwWPzK7YfbcIvPo3riFGsUkaGxNJoGiNPOvHD8vZ83ecqMQGkHPOpgHtSbe94uokE1AJvpbyQ==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.3.1.tgz", + "integrity": "sha512-1MN8i1Tnc8d8RugQGqv5jp+Ce2xtNhrnbm0ZZbe5ceExj9C2PkKZfHJhY9kbdUS4G7xSVwKlVdMTmLlStepOtw==", "license": "MIT", "workspaces": [ "examples/*" @@ -6537,7 +6228,7 @@ "monaco-types": "^0.1.0", "monaco-worker-manager": "^2.0.0", "path-browserify": "^1.0.0", - "prettier": "^2.0.0", + "prettier": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.0", "vscode-languageserver-types": "^3.0.0", "vscode-uri": "^3.0.0", @@ -6550,50 +6241,38 @@ "monaco-editor": ">=0.36" } }, - "node_modules/monaco-yaml/node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, "node_modules/msw": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.3.5.tgz", - "integrity": "sha512-+GUI4gX5YC5Bv33epBrD+BGdmDvBg2XGruiWnI3GbIbRmMMBeZ5gs3mJ51OWSGHgJKztZ8AtZeYMMNMVrje2/Q==", + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.3.tgz", + "integrity": "sha512-+mycXv8l2fEAjFZ5sjrtjJDmm2ceKGjrNbBr1durRg6VkU9fNUE/gsmQ51hWbHqs+l35W1iM+ZsmOD9Fd6lspw==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@bundled-es-modules/cookie": "^2.0.0", + "@bundled-es-modules/cookie": "^2.0.1", "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", - "@inquirer/confirm": "^3.0.0", - "@mswjs/interceptors": "^0.29.0", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.37.0", + "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", - "chalk": "^4.1.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", - "outvariant": "^1.4.2", - "path-to-regexp": "^6.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", "strict-event-emitter": "^0.5.1", - "type-fest": "^4.9.0", + "type-fest": "^4.26.1", "yargs": "^17.7.2" }, "bin": { @@ -6606,7 +6285,7 @@ "url": "https://github.com/sponsors/mswjs" }, "peerDependencies": { - "typescript": ">= 4.7.x" + "typescript": ">= 4.8.x" }, "peerDependenciesMeta": { "typescript": { @@ -6615,10 +6294,11 @@ } }, "node_modules/msw/node_modules/type-fest": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.2.tgz", - "integrity": "sha512-anpAG63wSpdEbLwOqH8L84urkL6PiVIov3EMmgIhhThevh9aiMQov+6Btx0wldNcvm4wV+e2/Rt1QdDwKHFbHw==", + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.37.0.tgz", + "integrity": "sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" }, @@ -6627,12 +6307,13 @@ } }, "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, + "license": "ISC", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/mz": { @@ -6646,15 +6327,16 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -6823,10 +6505,11 @@ } }, "node_modules/outvariant": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.2.tgz", - "integrity": "sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==", - "dev": true + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" }, "node_modules/p-limit": { "version": "3.1.0", @@ -6944,10 +6627,11 @@ } }, "node_modules/path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", - "dev": true + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", @@ -6960,9 +6644,9 @@ } }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, @@ -7010,9 +6694,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "funding": [ { "type": "opencollective", @@ -7029,8 +6713,8 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -7161,7 +6845,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", - "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -7469,9 +7152,10 @@ } }, "node_modules/react-icons": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz", - "integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", "peerDependencies": { "react": "*" } @@ -7528,23 +7212,23 @@ } }, "node_modules/react-remove-scroll": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", - "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", + "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==", "license": "MIT", "dependencies": { - "react-remove-scroll-bar": "^2.3.6", - "react-style-singleton": "^2.2.1", + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" }, "engines": { "node": ">=10" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -7553,20 +7237,20 @@ } }, "node_modules/react-remove-scroll-bar": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "license": "MIT", "dependencies": { - "react-style-singleton": "^2.2.1", + "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "engines": { "node": ">=10" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -7628,21 +7312,20 @@ } }, "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", "license": "MIT", "dependencies": { "get-nonce": "^1.0.0", - "invariant": "^2.2.4", "tslib": "^2.0.0" }, "engines": { "node": ">=10" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -7651,11 +7334,12 @@ } }, "node_modules/react-swipeable": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.1.tgz", - "integrity": "sha512-RKB17JdQzvECfnVj9yDZsiYn3vH0eyva/ZbrCZXZR0qp66PBRhtg4F9yJcJTWYT5Adadi+x4NoG53BxKHwIYLQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz", + "integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==", + "license": "MIT", "peerDependencies": { - "react": "^16.8.3 || ^17 || ^18" + "react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc" } }, "node_modules/react-tracked": { @@ -7841,12 +7525,13 @@ } }, "node_modules/rollup": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", - "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", + "integrity": "sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -7856,19 +7541,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.0", - "@rollup/rollup-android-arm64": "4.13.0", - "@rollup/rollup-darwin-arm64": "4.13.0", - "@rollup/rollup-darwin-x64": "4.13.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", - "@rollup/rollup-linux-arm64-gnu": "4.13.0", - "@rollup/rollup-linux-arm64-musl": "4.13.0", - "@rollup/rollup-linux-riscv64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-musl": "4.13.0", - "@rollup/rollup-win32-arm64-msvc": "4.13.0", - "@rollup/rollup-win32-ia32-msvc": "4.13.0", - "@rollup/rollup-win32-x64-msvc": "4.13.0", + "@rollup/rollup-android-arm-eabi": "4.34.9", + "@rollup/rollup-android-arm64": "4.34.9", + "@rollup/rollup-darwin-arm64": "4.34.9", + "@rollup/rollup-darwin-x64": "4.34.9", + "@rollup/rollup-freebsd-arm64": "4.34.9", + "@rollup/rollup-freebsd-x64": "4.34.9", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.9", + "@rollup/rollup-linux-arm-musleabihf": "4.34.9", + "@rollup/rollup-linux-arm64-gnu": "4.34.9", + "@rollup/rollup-linux-arm64-musl": "4.34.9", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.9", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.9", + "@rollup/rollup-linux-riscv64-gnu": "4.34.9", + "@rollup/rollup-linux-s390x-gnu": "4.34.9", + "@rollup/rollup-linux-x64-gnu": "4.34.9", + "@rollup/rollup-linux-x64-musl": "4.34.9", + "@rollup/rollup-win32-arm64-msvc": "4.34.9", + "@rollup/rollup-win32-ia32-msvc": "4.34.9", + "@rollup/rollup-win32-x64-msvc": "4.34.9", "fsevents": "~2.3.2" } }, @@ -8000,15 +7691,6 @@ "node": ">=6" } }, - "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8172,9 +7854,9 @@ } }, "node_modules/std-env": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", - "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", + "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", "dev": true, "license": "MIT" }, @@ -8191,7 +7873,8 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/string-width": { "version": "4.2.3", @@ -8433,15 +8116,16 @@ } }, "node_modules/swr": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz", - "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.2.tgz", + "integrity": "sha512-RosxFpiabojs75IwQ316DGoDRmOqtiAj0tg8wCcbEu4CiLZBs/a9QNtHV7TUfDXmmlgqij/NqzKq/eLelyv9xA==", + "license": "MIT", "dependencies": { - "client-only": "^0.0.1", - "use-sync-external-store": "^1.2.0" + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" }, "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/symbol-tree": { @@ -8620,16 +8304,23 @@ } }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, "license": "MIT" }, "node_modules/tinypool": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.0.tgz", - "integrity": "sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", "dev": true, "license": "MIT", "engines": { @@ -8637,9 +8328,9 @@ } }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { @@ -8647,9 +8338,9 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, "license": "MIT", "engines": { @@ -8668,16 +8359,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8771,9 +8452,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8884,9 +8565,9 @@ } }, "node_modules/use-callback-ref": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -8895,8 +8576,8 @@ "node": ">=10" }, "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -8923,9 +8604,9 @@ } }, "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", "license": "MIT", "dependencies": { "detect-node-es": "^1.1.0", @@ -8935,8 +8616,8 @@ "node": ">=10" }, "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -8945,11 +8626,12 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/util-deprecate": { @@ -9000,21 +8682,21 @@ } }, "node_modules/vite": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz", - "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", + "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.40", - "rollup": "^4.13.0" + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -9023,19 +8705,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -9056,27 +8744,33 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.7.tgz", + "integrity": "sha512-2fX0QwX4GkkkpULXdT1Pf4q0tC1i1lFOyseKoonavXUNlQ77KpW2XqBGGNIm/J4Ows4KxgGJzDguYVPKwG/n5A==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", - "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0" + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" }, "bin": { "vite-node": "vite-node.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -9091,46 +8785,48 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.7.tgz", + "integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", - "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", - "pathe": "^1.1.2", - "std-env": "^3.7.0", - "tinybench": "^2.8.0", - "tinypool": "^1.0.0", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.0.5", + "@vitest/expect": "3.0.7", + "@vitest/mocker": "3.0.7", + "@vitest/pretty-format": "^3.0.7", + "@vitest/runner": "3.0.7", + "@vitest/snapshot": "3.0.7", + "@vitest/spy": "3.0.7", + "@vitest/utils": "3.0.7", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.7", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.7", + "@vitest/ui": "3.0.7", "happy-dom": "*", "jsdom": "*" }, @@ -9138,6 +8834,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, @@ -9155,6 +8854,33 @@ } } }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.7.tgz", + "integrity": "sha512-qui+3BLz9Eonx4EAuR/i+QlCX6AUZ35taDQgwGkK/Tw6/WgwodSrjN1X2xf69IA/643ZX5zNKIn2svvtZDrs4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -9295,6 +9021,7 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -9375,9 +9102,13 @@ } }, "node_modules/yaml": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", - "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { "node": ">= 14" } @@ -9421,6 +9152,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/web/package.json b/web/package.json index 700fd12d7..59a0a5d03 100644 --- a/web/package.json +++ b/web/package.json @@ -14,45 +14,45 @@ "coverage": "vitest run --coverage" }, "dependencies": { - "@cycjimmy/jsmpeg-player": "^6.1.1", + "@cycjimmy/jsmpeg-player": "^6.1.2", "@hookform/resolvers": "^3.9.0", "@melloware/react-logviewer": "^6.1.2", - "@radix-ui/react-alert-dialog": "^1.1.2", - "@radix-ui/react-aspect-ratio": "^1.1.0", - "@radix-ui/react-checkbox": "^1.1.2", - "@radix-ui/react-context-menu": "^2.2.2", - "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-hover-card": "^1.1.2", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-popover": "^1.1.2", - "@radix-ui/react-radio-group": "^1.2.1", - "@radix-ui/react-scroll-area": "^1.2.0", - "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-aspect-ratio": "^1.1.2", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-switch": "^1.1.1", - "@radix-ui/react-tabs": "^1.1.1", - "@radix-ui/react-toggle": "^1.1.0", - "@radix-ui/react-toggle-group": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.3", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", "apexcharts": "^3.52.0", "axios": "^1.7.7", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", "embla-carousel-react": "^8.2.0", "framer-motion": "^11.5.4", - "hls.js": "^1.5.17", + "hls.js": "^1.5.20", "idb-keyval": "^6.2.1", "immer": "^10.1.1", - "konva": "^9.3.16", + "konva": "^9.3.18", "lodash": "^4.17.21", - "lucide-react": "^0.407.0", - "monaco-yaml": "^5.2.2", + "lucide-react": "^0.477.0", + "monaco-yaml": "^5.3.1", "next-themes": "^0.3.0", "nosleep.js": "^0.12.0", "react": "^18.3.1", @@ -62,10 +62,10 @@ "react-dom": "^18.3.1", "react-grid-layout": "^1.5.0", "react-hook-form": "^7.52.1", - "react-icons": "^5.2.1", + "react-icons": "^5.5.0", "react-konva": "^18.2.10", "react-router-dom": "^6.26.0", - "react-swipeable": "^7.0.1", + "react-swipeable": "^7.0.2", "react-tracked": "^2.0.1", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", @@ -75,7 +75,7 @@ "sonner": "^1.5.0", "sort-by": "^1.2.0", "strftime": "^0.10.3", - "swr": "^2.2.5", + "swr": "^2.3.2", "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", @@ -97,8 +97,8 @@ "@types/strftime": "^0.9.8", "@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/parser": "^7.5.0", - "@vitejs/plugin-react-swc": "^3.7.1", - "@vitest/coverage-v8": "^2.0.5", + "@vitejs/plugin-react-swc": "^3.8.0", + "@vitest/coverage-v8": "^3.0.7", "autoprefixer": "^10.4.20", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", @@ -115,8 +115,8 @@ "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.5", "tailwindcss": "^3.4.9", - "typescript": "^5.5.4", - "vite": "^5.4.0", - "vitest": "^2.0.5" + "typescript": "^5.8.2", + "vite": "^6.2.0", + "vitest": "^3.0.7" } } diff --git a/web/src/components/graph/CombinedStorageGraph.tsx b/web/src/components/graph/CombinedStorageGraph.tsx index 2a52d82b6..7f10f0ab5 100644 --- a/web/src/components/graph/CombinedStorageGraph.tsx +++ b/web/src/components/graph/CombinedStorageGraph.tsx @@ -16,7 +16,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { getUnitSize } from "@/utils/storageUtil"; -import { LuAlertCircle } from "react-icons/lu"; +import { CiCircleAlert } from "react-icons/ci"; type CameraStorage = { [key: string]: { @@ -199,7 +199,7 @@ export function CombinedStorageGraph({ className="focus:outline-none" aria-label="Unused Storage Information" > - diff --git a/web/src/components/indicators/activity-indicator.tsx b/web/src/components/indicators/activity-indicator.tsx index 5591c9b7d..677a815ae 100644 --- a/web/src/components/indicators/activity-indicator.tsx +++ b/web/src/components/indicators/activity-indicator.tsx @@ -1,5 +1,5 @@ import { cn } from "@/lib/utils"; -import { LuLoader2 } from "react-icons/lu"; +import { AiOutlineLoading3Quarters } from "react-icons/ai"; export default function ActivityIndicator({ className = "w-full", size = 30 }) { return ( @@ -7,7 +7,7 @@ export default function ActivityIndicator({ className = "w-full", size = 30 }) { className={cn("flex items-center justify-center", className)} aria-label="Loading…" > - +
); } diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index 7473d26d7..c6f920461 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -5,7 +5,7 @@ import { LuList, LuLogOut, LuMoon, - LuPenSquare, + LuSquarePen, LuRotateCw, LuSettings, LuSun, @@ -200,7 +200,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { } aria-label="Configuration editor" > - + Configuration editor diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index fee12a50f..8db67e43e 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -4,7 +4,8 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { baseUrl } from "@/api/baseUrl"; import { toast } from "sonner"; import axios from "axios"; -import { LuCamera, LuDownload, LuMoreVertical, LuTrash2 } from "react-icons/lu"; +import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu"; +import { FiMoreVertical } from "react-icons/fi"; import { FaArrowsRotate } from "react-icons/fa6"; import { MdImageSearch } from "react-icons/md"; import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; @@ -216,7 +217,7 @@ export default function SearchResultActions({ - + {menuItems} diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx index 40ab543c3..da9bd61b0 100644 --- a/web/src/components/overlay/detail/ObjectLifecycle.tsx +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -23,7 +23,6 @@ import { LuEar, LuFolderX, LuPlay, - LuPlayCircle, LuSettings, LuTruck, } from "react-icons/lu"; @@ -54,6 +53,7 @@ import { import { useNavigate } from "react-router-dom"; import { ObjectPath } from "./ObjectPath"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; +import { IoPlayCircleOutline } from "react-icons/io5"; type ObjectLifecycleProps = { className?: string; @@ -733,7 +733,7 @@ export function LifecycleIcon({ case "gone": return ; case "active": - return ; + return ; case "stationary": return ; case "entered_zone": diff --git a/web/src/views/explore/ExploreView.tsx b/web/src/views/explore/ExploreView.tsx index c3ef25ad1..e754bc1bf 100644 --- a/web/src/views/explore/ExploreView.tsx +++ b/web/src/views/explore/ExploreView.tsx @@ -3,7 +3,7 @@ import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect"; import useSWR from "swr"; import { useApiHost } from "@/api"; import { cn } from "@/lib/utils"; -import { LuArrowRightCircle } from "react-icons/lu"; +import { BsArrowRightCircle } from "react-icons/bs"; import { useNavigate } from "react-router-dom"; import { Tooltip, @@ -183,7 +183,7 @@ function ThumbnailRow({ > - diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index fcda4adb1..d30de487e 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -20,7 +20,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import axios from "axios"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { LuAlertCircle, LuCheck, LuExternalLink, LuX } from "react-icons/lu"; +import { LuCheck, LuExternalLink, LuX } from "react-icons/lu"; +import { CiCircleAlert } from "react-icons/ci"; import { Link } from "react-router-dom"; import { toast } from "sonner"; import useSWR from "swr"; @@ -311,7 +312,7 @@ export default function NotificationView({
- + Notifications Unavailable diff --git a/web/src/views/system/StorageMetrics.tsx b/web/src/views/system/StorageMetrics.tsx index ea2f1b7b2..4f057be29 100644 --- a/web/src/views/system/StorageMetrics.tsx +++ b/web/src/views/system/StorageMetrics.tsx @@ -8,7 +8,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import useSWR from "swr"; -import { LuAlertCircle } from "react-icons/lu"; +import { CiCircleAlert } from "react-icons/ci"; import { FrigateConfig } from "@/types/frigateConfig"; import { useTimezone } from "@/hooks/use-date-utils"; import { RecordingsSummary } from "@/types/review"; @@ -86,7 +86,7 @@ export default function StorageMetrics({ className="focus:outline-none" aria-label="Unused Storage Information" > - From f81bab8895d693a96ab2ef87fef28c2e29cb9365 Mon Sep 17 00:00:00 2001 From: Chris Oelerich Date: Thu, 6 Mar 2025 17:21:16 -0500 Subject: [PATCH 31/82] video['global'] can be empty resulting in a divide by zero (#16993) * video['global'] can be empty resulting in a divide by zero * formatting :( --- frigate/util/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/util/services.py b/frigate/util/services.py index c9c1b61a2..ce7041c26 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -362,7 +362,7 @@ def get_intel_gpu_stats(sriov: bool) -> dict[str, str]: if video_frame is not None: video[key].append(float(video_frame)) - if render["global"]: + if render["global"] and video["global"]: results["gpu"] = ( f"{round(((sum(render['global']) / len(render['global'])) + (sum(video['global']) / len(video['global']))) / 2, 2)}%" ) From 0e3e2e5cccdc669ebd5dc0842411fbf4cd6726d7 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 6 Mar 2025 20:00:15 -0600 Subject: [PATCH 32/82] Add cameras filter to history view (#16995) --- .../components/filter/CamerasFilterButton.tsx | 26 +++++++++++++++++-- .../components/filter/ReviewFilterGroup.tsx | 3 +++ web/src/views/recording/RecordingView.tsx | 19 ++++++++++++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx index c584dc09d..d9deb87bd 100644 --- a/web/src/components/filter/CamerasFilterButton.tsx +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -18,6 +18,7 @@ type CameraFilterButtonProps = { groups: [string, CameraGroupConfig][]; selectedCameras: string[] | undefined; hideText?: boolean; + mainCamera?: string; updateCameraFilter: (cameras: string[] | undefined) => void; }; export function CamerasFilterButton({ @@ -25,6 +26,7 @@ export function CamerasFilterButton({ groups, selectedCameras, hideText = isMobile, + mainCamera, updateCameraFilter, }: CameraFilterButtonProps) { const [open, setOpen] = useState(false); @@ -74,6 +76,7 @@ export function CamerasFilterButton({ allCameras={allCameras} groups={groups} currentCameras={currentCameras} + mainCamera={mainCamera} setCurrentCameras={setCurrentCameras} setOpen={setOpen} updateCameraFilter={updateCameraFilter} @@ -120,6 +123,7 @@ export function CamerasFilterButton({ type CamerasFilterContentProps = { allCameras: string[]; currentCameras: string[] | undefined; + mainCamera?: string; groups: [string, CameraGroupConfig][]; setCurrentCameras: (cameras: string[] | undefined) => void; setOpen: (open: boolean) => void; @@ -128,6 +132,7 @@ type CamerasFilterContentProps = { export function CamerasFilterContent({ allCameras, currentCameras, + mainCamera, groups, setCurrentCameras, setOpen, @@ -178,12 +183,29 @@ export function CamerasFilterContent({ key={item} isChecked={currentCameras?.includes(item) ?? false} label={item.replaceAll("_", " ")} + disabled={ + mainCamera !== undefined && + currentCameras !== undefined && + item === mainCamera + } // Disable only if mainCamera exists and cameras are filtered onCheckedChange={(isChecked) => { + if ( + mainCamera !== undefined && // Only enforce if mainCamera is defined + item === mainCamera && + !isChecked && + currentCameras !== undefined + ) { + return; // Prevent deselecting mainCamera when filtered and mainCamera is defined + } if (isChecked) { const updatedCameras = currentCameras ? [...currentCameras] - : []; - updatedCameras.push(item); + : mainCamera !== undefined && item !== mainCamera // If mainCamera exists and this isn’t it + ? [mainCamera] // Start with mainCamera when transitioning from undefined + : []; // Otherwise start empty + if (!updatedCameras.includes(item)) { + updatedCameras.push(item); + } setCurrentCameras(updatedCameras); } else { const updatedCameras = currentCameras diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index dedcb06fc..09eb8092a 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -49,6 +49,7 @@ type ReviewFilterGroupProps = { motionOnly: boolean; filterList?: FilterList; showReviewed: boolean; + mainCamera?: string; setShowReviewed: (show: boolean) => void; onUpdateFilter: (filter: ReviewFilter) => void; setMotionOnly: React.Dispatch>; @@ -63,6 +64,7 @@ export default function ReviewFilterGroup({ motionOnly, filterList, showReviewed, + mainCamera, setShowReviewed, onUpdateFilter, setMotionOnly, @@ -185,6 +187,7 @@ export default function ReviewFilterGroup({ allCameras={filterValues.cameras} groups={groups} selectedCameras={filter?.cameras} + mainCamera={mainCamera} updateCameraFilter={(newCameras) => { onUpdateFilter({ ...filter, cameras: newCameras }); }} diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index c5e528736..0baeca994 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -442,7 +442,7 @@ export function RecordingView({ )} {isDesktop && ( {}} - onUpdateFilter={updateFilter} + mainCamera={mainCamera} + onUpdateFilter={(newFilter: ReviewFilter) => { + const updatedCameras = + newFilter.cameras === undefined + ? undefined // Respect undefined as "all cameras" + : newFilter.cameras + ? Array.from( + new Set([mainCamera, ...(newFilter.cameras || [])]), + ) // Include mainCamera if specific cameras are selected + : [mainCamera]; + const adjustedFilter: ReviewFilter = { + ...newFilter, + cameras: updatedCameras, + }; + updateFilter(adjustedFilter); + }} setMotionOnly={() => {}} /> )} From d831fab381aacd6d7c54dd213785f9aa4fd5769e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Mar 2025 06:47:15 -0700 Subject: [PATCH 33/82] Bump actions/setup-python from 5.3.0 to 5.4.0 (#16184) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.3.0 to 5.4.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.3.0...v5.4.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 37f75bf85..02fde5861 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -80,7 +80,7 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.3.0 + uses: actions/setup-python@v5.4.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements From 09705fd1b41067e8c1f72679eb54f97ed04f2c7d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 7 Mar 2025 06:54:53 -0700 Subject: [PATCH 34/82] Update python deps (#17006) --- docker/main/requirements-wheels.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 25286617e..795007e86 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -1,7 +1,7 @@ aiofiles == 24.1.* click == 8.1.* # FastAPI -aiohttp == 3.11.2 +aiohttp == 3.11.3 starlette == 0.41.2 starlette-context == 0.3.6 fastapi == 0.115.* @@ -20,9 +20,9 @@ pandas == 2.2.* peewee == 3.17.* peewee_migrate == 1.13.* psutil == 6.1.* -pydantic == 2.8.* +pydantic == 2.10.* git+https://github.com/fbcotter/py3nvml#egg=py3nvml -pytz == 2024.* +pytz == 2025.* pyzmq == 26.2.* ruamel.yaml == 0.18.* tzlocal == 5.2 @@ -34,8 +34,8 @@ ws4py == 0.5.* unidecode == 1.3.* # Image Manipulation numpy == 1.26.* -opencv-python-headless == 4.10.0.* -opencv-contrib-python == 4.9.0.* +opencv-python-headless == 4.11.0.* +opencv-contrib-python == 4.11.0.* scipy == 1.14.* # OpenVino & ONNX openvino == 2024.4.* @@ -46,7 +46,7 @@ transformers == 4.45.* # Generative AI google-generativeai == 0.8.* ollama == 0.3.* -openai == 1.51.* +openai == 1.65.* # push notifications py-vapid == 1.9.* pywebpush == 2.0.* From 6f9d9cd5a853b7c7cb4058ada8dccdca8cbc3f5b Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 7 Mar 2025 07:50:04 -0700 Subject: [PATCH 35/82] Fix yolov9 link (#17007) --- docs/docs/configuration/object_detectors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 6834f8014..3423ac3c6 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -230,7 +230,7 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl #### YOLOv9 -[YOLOv9](https://github.com/MultimediaTechLab/YOLO) models are supported, but not included by default. +[YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default. :::tip @@ -513,7 +513,7 @@ model: #### YOLOv9 -[YOLOv9](https://github.com/MultimediaTechLab/YOLO) models are supported, but not included by default. +[YOLOv9](https://github.com/WongKinYiu/yolov9) models are supported, but not included by default. :::tip From 74ca009b0b767a8393562a932673f8fd5b11b7fe Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 8 Mar 2025 10:01:08 -0600 Subject: [PATCH 36/82] UI viewer role (#16978) * db migration * db model * assign admin role on password reset * add role to jwt and api responses * don't restrict api access for admins yet * use json response * frontend auth context * update auth form for profile endpoint * add access denied page * add protected routes * auth hook * dialogs * user settings view * restrict viewer access to settings * restrict camera functions for viewer role * add password dialog to account menu * spacing tweak * migrator default to admin * escape quotes in migrator * ui tweaks * tweaks * colors * colors * fix merge conflict * fix icons * add api layer enforcement * ui tweaks * fix error message * debug * clean up * remove print * guard apis for admin only * fix tests * fix review tests * use correct error responses from api in toasts * add role to account menu --- .../usr/local/nginx/conf/auth_request.conf | 4 +- frigate/api/app.py | 7 +- frigate/api/auth.py | 177 ++++++-- frigate/api/classification.py | 11 +- frigate/api/defs/request/app_body.py | 7 + frigate/api/event.py | 55 ++- frigate/api/export.py | 9 +- frigate/api/review.py | 7 +- frigate/app.py | 1 + frigate/config/proxy.py | 4 + frigate/models.py | 4 + frigate/test/http_api/test_http_review.py | 18 +- frigate/test/test_http.py | 9 +- migrations/029_add_user_role.py | 37 ++ web/src/App.tsx | 83 ++-- web/src/components/auth/AuthForm.tsx | 20 +- web/src/components/auth/ProtectedRoute.tsx | 40 ++ .../components/filter/CameraGroupSelector.tsx | 22 +- .../components/filter/SearchActionGroup.tsx | 8 +- web/src/components/menu/AccountSettings.tsx | 53 ++- web/src/components/menu/GeneralSettings.tsx | 417 ++++++++++-------- .../components/menu/SearchResultActions.tsx | 8 +- .../components/overlay/CreateUserDialog.tsx | 229 ++++++++-- .../components/overlay/DeleteUserDialog.tsx | 59 ++- web/src/components/overlay/ExportDialog.tsx | 17 +- .../overlay/MobileReviewSettingsDrawer.tsx | 17 +- .../components/overlay/RoleChangeDialog.tsx | 119 +++++ .../components/overlay/SetPasswordDialog.tsx | 198 ++++++++- .../overlay/detail/AnnotationSettingsPane.tsx | 11 +- .../overlay/detail/SearchDetailDialog.tsx | 26 +- .../settings/MotionMaskEditPane.tsx | 11 +- .../settings/ObjectMaskEditPane.tsx | 11 +- web/src/components/settings/PolygonItem.tsx | 11 +- web/src/components/settings/ZoneEditPane.tsx | 11 +- web/src/context/auth-context.tsx | 74 ++++ web/src/context/providers.tsx | 29 +- web/src/hooks/use-is-admin.ts | 10 + web/src/pages/AccessDenied.tsx | 21 + web/src/pages/ConfigEditor.tsx | 11 +- web/src/pages/Exports.tsx | 17 +- web/src/pages/FaceLibrary.tsx | 101 ++--- web/src/pages/Settings.tsx | 26 +- web/src/types/user.ts | 1 + web/src/views/events/EventView.tsx | 17 +- web/src/views/live/LiveCameraView.tsx | 228 +++++----- web/src/views/settings/AuthenticationView.tsx | 372 ++++++++++++---- web/src/views/settings/CameraSettingsView.tsx | 11 +- .../settings/NotificationsSettingsView.tsx | 11 +- web/src/views/settings/SearchSettingsView.tsx | 11 +- web/src/views/settings/UiSettingsView.tsx | 22 +- 50 files changed, 1951 insertions(+), 732 deletions(-) create mode 100644 migrations/029_add_user_role.py create mode 100644 web/src/components/auth/ProtectedRoute.tsx create mode 100644 web/src/components/overlay/RoleChangeDialog.tsx create mode 100644 web/src/context/auth-context.tsx create mode 100644 web/src/hooks/use-is-admin.ts create mode 100644 web/src/pages/AccessDenied.tsx diff --git a/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf b/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf index b054a6b97..9e745b6dc 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/auth_request.conf @@ -1,14 +1,16 @@ ## Send a subrequest to verify if the user is authenticated and has permission to access the resource. auth_request /auth; -## Save the upstream metadata response headers from Authelia to variables. +## Save the upstream metadata response headers from the auth request to variables auth_request_set $user $upstream_http_remote_user; +auth_request_set $role $upstream_http_remote_role; auth_request_set $groups $upstream_http_remote_groups; auth_request_set $name $upstream_http_remote_name; auth_request_set $email $upstream_http_remote_email; ## Inject the metadata response headers from the variables into the request made to the backend. proxy_set_header Remote-User $user; +proxy_set_header Remote-Role $role; proxy_set_header Remote-Groups $groups; proxy_set_header Remote-Email $email; proxy_set_header Remote-Name $name; diff --git a/frigate/api/app.py b/frigate/api/app.py index c55e36a4b..5ce90130f 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -22,6 +22,7 @@ from markupsafe import escape from peewee import operator from pydantic import ValidationError +from frigate.api.auth import require_role from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.request.app_body import AppConfigSetBody from frigate.api.defs.tags import Tags @@ -201,7 +202,7 @@ def config_raw(): ) -@router.post("/config/save") +@router.post("/config/save", dependencies=[Depends(require_role(["admin"]))]) def config_save(save_option: str, body: Any = Body(media_type="text/plain")): new_config = body.decode() if not new_config: @@ -326,7 +327,7 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")): ) -@router.put("/config/set") +@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) def config_set(request: Request, body: AppConfigSetBody): config_file = find_config_file() @@ -542,7 +543,7 @@ async def logs( ) -@router.post("/restart") +@router.post("/restart", dependencies=[Depends(require_role(["admin"]))]) def restart(): try: restart_frigate() diff --git a/frigate/api/auth.py b/frigate/api/auth.py index be5917450..1752b19c9 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -11,8 +11,9 @@ import secrets import time from datetime import datetime from pathlib import Path +from typing import List -from fastapi import APIRouter, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse, RedirectResponse from joserfc import jwt from peewee import DoesNotExist @@ -22,6 +23,7 @@ from frigate.api.defs.request.app_body import ( AppPostLoginBody, AppPostUsersBody, AppPutPasswordBody, + AppPutRoleBody, ) from frigate.api.defs.tags import Tags from frigate.config import AuthConfig, ProxyConfig @@ -169,8 +171,10 @@ def verify_password(password, password_hash): return secrets.compare_digest(password_hash, compare_hash) -def create_encoded_jwt(user, expiration, secret): - return jwt.encode({"alg": "HS256"}, {"sub": user, "exp": expiration}, secret) +def create_encoded_jwt(user, role, expiration, secret): + return jwt.encode( + {"alg": "HS256"}, {"sub": user, "role": role, "exp": expiration}, secret + ) def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, secure): @@ -184,7 +188,48 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, sec ) -# Endpoint for use with nginx auth_request +async def get_current_user(request: Request): + JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name + encoded_token = request.cookies.get(JWT_COOKIE_NAME) + if not encoded_token: + return JSONResponse(content={"message": "No JWT token found"}, status_code=401) + + try: + token = jwt.decode(encoded_token, request.app.jwt_token) + if "sub" not in token.claims or "role" not in token.claims: + return JSONResponse( + content={"message": "Invalid JWT token"}, status_code=401 + ) + 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]): + async def role_checker(request: Request): + # Get role from header (could be comma-separated) + role_header = request.headers.get("remote-role") + roles = [r.strip() for r in role_header.split(",")] if role_header else [] + + # Check if we have any roles + if not roles: + raise HTTPException(status_code=403, detail="Role not provided") + + # Check if any role matches required_roles + if not any(role in required_roles for role in roles): + raise HTTPException( + status_code=403, + detail=f"Role {', '.join(roles)} not authorized. Required: {', '.join(required_roles)}", + ) + + # Return the first matching role + return next((role for role in roles if role in required_roles), roles[0]) + + return role_checker + + +# Endpoints @router.get("/auth") def auth(request: Request): auth_config: AuthConfig = request.app.frigate_config.auth @@ -195,6 +240,8 @@ def auth(request: Request): # dont require auth if the request is on the internal port # this header is set by Frigate's nginx proxy, so it cant be spoofed if int(request.headers.get("x-server-port", default=0)) == 5000: + success_response.headers["remote-user"] = "anonymous" + success_response.headers["remote-role"] = "admin" return success_response fail_response = Response("", status_code=401) @@ -211,14 +258,18 @@ def auth(request: Request): if not auth_config.enabled: # pass the user header value from the upstream proxy if a mapping is specified # or use anonymous if none are specified - if proxy_config.header_map.user is not None: - upstream_user_header_value = request.headers.get( - proxy_config.header_map.user, - default="anonymous", - ) - success_response.headers["remote-user"] = upstream_user_header_value - else: - success_response.headers["remote-user"] = "anonymous" + user_header = proxy_config.header_map.user + role_header = proxy_config.header_map.get("role", "Remote-Role") + success_response.headers["remote-user"] = ( + request.headers.get(user_header, default="anonymous") + if user_header + else "anonymous" + ) + success_response.headers["remote-role"] = ( + request.headers.get(role_header, default="viewer") + if role_header + else "viewer" + ) return success_response # now apply authentication @@ -251,11 +302,15 @@ def auth(request: Request): if "sub" not in token.claims: logger.debug("user not set in jwt token") return fail_response + if "role" not in token.claims: + logger.debug("role not set in jwt token") + return fail_response if "exp" not in token.claims: logger.debug("exp not set in jwt token") return fail_response user = token.claims.get("sub") + role = token.claims.get("role") current_time = int(time.time()) # if the jwt is expired @@ -283,7 +338,7 @@ def auth(request: Request): return fail_response new_expiration = current_time + JWT_SESSION_LENGTH new_encoded_jwt = create_encoded_jwt( - user, new_expiration, request.app.jwt_token + user, role, new_expiration, request.app.jwt_token ) set_jwt_cookie( success_response, @@ -294,6 +349,7 @@ def auth(request: Request): ) success_response.headers["remote-user"] = user + success_response.headers["remote-role"] = role return success_response except Exception as e: logger.error(f"Error parsing jwt: {e}") @@ -302,8 +358,16 @@ def auth(request: Request): @router.get("/profile") def profile(request: Request): - username = request.headers.get("remote-user") - return JSONResponse(content={"username": username}) + username = request.headers.get("remote-user", "anonymous") + if username != "anonymous": + try: + user = User.get_by_id(username) + role = getattr(user, "role", "viewer") + except DoesNotExist: + role = "viewer" # Fallback if user deleted + else: + role = None + return JSONResponse(content={"username": username, "role": role}) @router.get("/logout") @@ -333,8 +397,11 @@ def login(request: Request, body: AppPostLoginBody): password_hash = db_user.password_hash if verify_password(password, password_hash): + role = getattr(db_user, "role", "viewer") + if role not in ["admin", "viewer"]: + role = "viewer" # Enforce valid roles expiration = int(time.time()) + JWT_SESSION_LENGTH - encoded_jwt = create_encoded_jwt(user, expiration, request.app.jwt_token) + encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token) response = Response("", 200) set_jwt_cookie( response, JWT_COOKIE_NAME, encoded_jwt, expiration, JWT_COOKIE_SECURE @@ -343,25 +410,31 @@ def login(request: Request, body: AppPostLoginBody): return JSONResponse(content={"message": "Login failed"}, status_code=401) -@router.get("/users") +@router.get("/users", dependencies=[Depends(require_role(["admin"]))]) def get_users(): - exports = User.select(User.username).order_by(User.username).dicts().iterator() + exports = ( + User.select(User.username, User.role).order_by(User.username).dicts().iterator() + ) return JSONResponse([e for e in exports]) -@router.post("/users") -def create_user(request: Request, body: AppPostUsersBody): +@router.post("/users", dependencies=[Depends(require_role(["admin"]))]) +def create_user( + request: Request, + body: AppPostUsersBody, +): HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations if not re.match("^[A-Za-z0-9._]+$", body.username): - JSONResponse(content={"message": "Invalid username"}, status_code=400) + return JSONResponse(content={"message": "Invalid username"}, status_code=400) + role = body.role if body.role in ["admin", "viewer"] else "viewer" password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) - User.insert( { User.username: body.username, User.password_hash: password_hash, + User.role: role, User.notification_tokens: [], } ).execute() @@ -375,15 +448,61 @@ def delete_user(username: str): @router.put("/users/{username}/password") -def update_password(request: Request, username: str, body: AppPutPasswordBody): +async def update_password( + request: Request, + username: str, + body: AppPutPasswordBody, +): + current_user = await get_current_user(request) + if isinstance(current_user, JSONResponse): + # auth failed + return current_user + + current_username = current_user.get("username") + current_role = current_user.get("role") + + # viewers can only change their own password + if current_role == "viewer" and current_username != username: + raise HTTPException( + status_code=403, detail="Viewers can only update their own password" + ) + HASH_ITERATIONS = request.app.frigate_config.auth.hash_iterations password_hash = hash_password(body.password, iterations=HASH_ITERATIONS) + User.set_by_id(username, {User.password_hash: password_hash}) - User.set_by_id( - username, - { - User.password_hash: password_hash, - }, - ) + return JSONResponse(content={"success": True}) + + +@router.put( + "/users/{username}/role", + dependencies=[Depends(require_role(["admin"]))], +) +async def update_role( + request: Request, + username: str, + body: AppPutRoleBody, +): + current_user = await get_current_user(request) + if isinstance(current_user, JSONResponse): + # auth failed + return current_user + + current_role = current_user.get("role") + # viewers can't change anyone's role + if current_role == "viewer": + raise HTTPException( + status_code=403, detail="Admin role is required to change user roles" + ) + if username == "admin": + return JSONResponse( + content={"message": "Cannot modify admin user's role"}, status_code=403 + ) + if body.role not in ["admin", "viewer"]: + return JSONResponse( + content={"message": "Role must be 'admin' or 'viewer'"}, status_code=400 + ) + + User.set_by_id(username, {User.role: body.role}) return JSONResponse(content={"success": True}) diff --git a/frigate/api/classification.py b/frigate/api/classification.py index bd395737a..85b604379 100644 --- a/frigate/api/classification.py +++ b/frigate/api/classification.py @@ -6,12 +6,13 @@ import random import shutil import string -from fastapi import APIRouter, Request, UploadFile +from fastapi import APIRouter, Depends, Request, UploadFile from fastapi.responses import JSONResponse from pathvalidate import sanitize_filename from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.tags import Tags from frigate.const import FACE_DIR from frigate.embeddings import EmbeddingsContext @@ -44,7 +45,7 @@ def get_faces(): return JSONResponse(status_code=200, content=face_dict) -@router.post("/faces/reprocess") +@router.post("/faces/reprocess", dependencies=[Depends(require_role(["admin"]))]) def reclassify_face(request: Request, body: dict = None): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( @@ -121,7 +122,7 @@ def train_face(request: Request, name: str, body: dict = None): ) -@router.post("/faces/{name}/create") +@router.post("/faces/{name}/create", dependencies=[Depends(require_role(["admin"]))]) async def create_face(request: Request, name: str): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( @@ -138,7 +139,7 @@ async def create_face(request: Request, name: str): ) -@router.post("/faces/{name}/register") +@router.post("/faces/{name}/register", dependencies=[Depends(require_role(["admin"]))]) async def register_face(request: Request, name: str, file: UploadFile): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( @@ -154,7 +155,7 @@ async def register_face(request: Request, name: str, file: UploadFile): ) -@router.post("/faces/{name}/delete") +@router.post("/faces/{name}/delete", dependencies=[Depends(require_role(["admin"]))]) def deregister_faces(request: Request, name: str, body: dict = None): if not request.app.frigate_config.face_recognition.enabled: return JSONResponse( diff --git a/frigate/api/defs/request/app_body.py b/frigate/api/defs/request/app_body.py index 85daa5631..1fc05db2f 100644 --- a/frigate/api/defs/request/app_body.py +++ b/frigate/api/defs/request/app_body.py @@ -1,3 +1,5 @@ +from typing import Optional + from pydantic import BaseModel @@ -12,8 +14,13 @@ class AppPutPasswordBody(BaseModel): class AppPostUsersBody(BaseModel): username: str password: str + role: Optional[str] = "viewer" class AppPostLoginBody(BaseModel): user: str password: str + + +class AppPutRoleBody(BaseModel): + role: str diff --git a/frigate/api/event.py b/frigate/api/event.py index 9a5578bae..100bdfd9e 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse from peewee import JOIN, DoesNotExist, fn, operator from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.query.events_query_parameters import ( DEFAULT_TIME_RANGE, EventsQueryParams, @@ -708,7 +709,11 @@ def event(event_id: str): return JSONResponse(content="Event not found", status_code=404) -@router.post("/events/{event_id}/retain", response_model=GenericResponse) +@router.post( + "/events/{event_id}/retain", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def set_retain(event_id: str): try: event = Event.get(Event.id == event_id) @@ -928,7 +933,11 @@ def false_positive(request: Request, event_id: str): ) -@router.delete("/events/{event_id}/retain", response_model=GenericResponse) +@router.delete( + "/events/{event_id}/retain", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def delete_retain(event_id: str): try: event = Event.get(Event.id == event_id) @@ -947,7 +956,11 @@ def delete_retain(event_id: str): ) -@router.post("/events/{event_id}/sub_label", response_model=GenericResponse) +@router.post( + "/events/{event_id}/sub_label", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def set_sub_label( request: Request, event_id: str, @@ -1022,7 +1035,11 @@ def set_sub_label( ) -@router.post("/events/{event_id}/description", response_model=GenericResponse) +@router.post( + "/events/{event_id}/description", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def set_description( request: Request, event_id: str, @@ -1069,7 +1086,11 @@ def set_description( ) -@router.put("/events/{event_id}/description/regenerate", response_model=GenericResponse) +@router.put( + "/events/{event_id}/description/regenerate", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def regenerate_description( request: Request, event_id: str, params: RegenerateQueryParameters = Depends() ): @@ -1137,14 +1158,22 @@ def delete_single_event(event_id: str, request: Request) -> dict: return {"success": True, "message": f"Event {event_id} deleted"} -@router.delete("/events/{event_id}", response_model=GenericResponse) +@router.delete( + "/events/{event_id}", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def delete_event(request: Request, event_id: str): result = delete_single_event(event_id, request) status_code = 200 if result["success"] else 404 return JSONResponse(content=result, status_code=status_code) -@router.delete("/events/", response_model=EventMultiDeleteResponse) +@router.delete( + "/events/", + response_model=EventMultiDeleteResponse, + dependencies=[Depends(require_role(["admin"]))], +) def delete_events(request: Request, body: EventsDeleteBody): if not body.event_ids: return JSONResponse( @@ -1170,7 +1199,11 @@ def delete_events(request: Request, body: EventsDeleteBody): return JSONResponse(content=response, status_code=200) -@router.post("/events/{camera_name}/{label}/create", response_model=EventCreateResponse) +@router.post( + "/events/{camera_name}/{label}/create", + response_model=EventCreateResponse, + dependencies=[Depends(require_role(["admin"]))], +) def create_event( request: Request, camera_name: str, @@ -1226,7 +1259,11 @@ def create_event( ) -@router.put("/events/{event_id}/end", response_model=GenericResponse) +@router.put( + "/events/{event_id}/end", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def end_event(request: Request, event_id: str, body: EventsEndBody): try: end_time = body.end_time or datetime.datetime.now().timestamp() diff --git a/frigate/api/export.py b/frigate/api/export.py index 2ccbc4beb..160434c68 100644 --- a/frigate/api/export.py +++ b/frigate/api/export.py @@ -6,11 +6,12 @@ import string from pathlib import Path import psutil -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse from peewee import DoesNotExist from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.request.export_recordings_body import ExportRecordingsBody from frigate.api.defs.request.export_rename_body import ExportRenameBody from frigate.api.defs.tags import Tags @@ -130,7 +131,9 @@ def export_recording( ) -@router.patch("/export/{event_id}/rename") +@router.patch( + "/export/{event_id}/rename", dependencies=[Depends(require_role(["admin"]))] +) def export_rename(event_id: str, body: ExportRenameBody): try: export: Export = Export.get(Export.id == event_id) @@ -158,7 +161,7 @@ def export_rename(event_id: str, body: ExportRenameBody): ) -@router.delete("/export/{event_id}") +@router.delete("/export/{event_id}", dependencies=[Depends(require_role(["admin"]))]) def export_delete(event_id: str): try: export: Export = Export.get(Export.id == event_id) diff --git a/frigate/api/review.py b/frigate/api/review.py index 3e503d400..4788356f3 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse from peewee import Case, DoesNotExist, fn, operator from playhouse.shortcuts import model_to_dict +from frigate.api.auth import require_role from frigate.api.defs.query.review_query_parameters import ( ReviewActivityMotionQueryParams, ReviewQueryParams, @@ -343,7 +344,11 @@ def set_multiple_reviewed(body: ReviewModifyMultipleBody): ) -@router.post("/reviews/delete", response_model=GenericResponse) +@router.post( + "/reviews/delete", + response_model=GenericResponse, + dependencies=[Depends(require_role(["admin"]))], +) def delete_reviews(body: ReviewModifyMultipleBody): list_of_ids = body.ids reviews = ( diff --git a/frigate/app.py b/frigate/app.py index 8b63ab0a0..cdb4877cc 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -620,6 +620,7 @@ class FrigateApp: ) User.replace( username="admin", + role="admin", password_hash=password_hash, notification_tokens=[], ).execute() diff --git a/frigate/config/proxy.py b/frigate/config/proxy.py index 3427f60a0..df8a665fb 100644 --- a/frigate/config/proxy.py +++ b/frigate/config/proxy.py @@ -12,6 +12,10 @@ class HeaderMappingConfig(FrigateBaseModel): user: str = Field( default=None, title="Header name from upstream proxy to identify user." ) + role: str = Field( + default=None, + title="Header name from upstream proxy to identify user role.", + ) class ProxyConfig(FrigateBaseModel): diff --git a/frigate/models.py b/frigate/models.py index 62bbf0bd3..26375432e 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -117,5 +117,9 @@ class RecordingsToDelete(Model): # type: ignore[misc] class User(Model): # type: ignore[misc] username = CharField(null=False, primary_key=True, max_length=30) + role = CharField( + max_length=20, + default="viewer", + ) password_hash = CharField(null=False, max_length=120) notification_tokens = JSONField() diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index c8f2b1719..ee7d96bc5 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -504,7 +504,7 @@ class TestHttpReview(BaseTestHttp): def test_post_reviews_delete_no_body(self): with TestClient(self.app) as client: super().insert_mock_review_segment("123456.random") - response = client.post("/reviews/delete") + response = client.post("/reviews/delete", headers={"remote-role": "admin"}) # Missing ids assert response.status_code == 422 @@ -512,7 +512,9 @@ class TestHttpReview(BaseTestHttp): with TestClient(self.app) as client: super().insert_mock_review_segment("123456.random") body = {"ids": [""]} - response = client.post("/reviews/delete", json=body) + response = client.post( + "/reviews/delete", json=body, headers={"remote-role": "admin"} + ) # Missing ids assert response.status_code == 422 @@ -521,7 +523,9 @@ class TestHttpReview(BaseTestHttp): id = "123456.random" super().insert_mock_review_segment(id) body = {"ids": ["1"]} - response = client.post("/reviews/delete", json=body) + response = client.post( + "/reviews/delete", json=body, headers={"remote-role": "admin"} + ) assert response.status_code == 200 response_json = response.json() assert response_json["success"] == True @@ -536,7 +540,9 @@ class TestHttpReview(BaseTestHttp): id = "123456.random" super().insert_mock_review_segment(id) body = {"ids": [id]} - response = client.post("/reviews/delete", json=body) + response = client.post( + "/reviews/delete", json=body, headers={"remote-role": "admin"} + ) assert response.status_code == 200 response_json = response.json() assert response_json["success"] == True @@ -558,7 +564,9 @@ class TestHttpReview(BaseTestHttp): assert len(recordings_ids_in_db_before) == 2 body = {"ids": ids} - response = client.post("/reviews/delete", json=body) + response = client.post( + "/reviews/delete", json=body, headers={"remote-role": "admin"} + ) assert response.status_code == 200 response_json = response.json() assert response_json["success"] == True diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index 0238c766c..d6ff91a83 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -172,7 +172,7 @@ class TestHttp(unittest.TestCase): event = client.get(f"/events/{id}").json() assert event assert event["id"] == id - client.delete(f"/events/{id}") + client.delete(f"/events/{id}", headers={"remote-role": "admin"}) event = client.get(f"/events/{id}").json() assert event == "Event not found" @@ -192,12 +192,12 @@ class TestHttp(unittest.TestCase): with TestClient(app) as client: _insert_mock_event(id) - client.post(f"/events/{id}/retain") + client.post(f"/events/{id}/retain", headers={"remote-role": "admin"}) event = client.get(f"/events/{id}").json() assert event assert event["id"] == id assert event["retain_indefinitely"] is True - client.delete(f"/events/{id}/retain") + client.delete(f"/events/{id}/retain", headers={"remote-role": "admin"}) event = client.get(f"/events/{id}").json() assert event assert event["id"] == id @@ -262,6 +262,7 @@ class TestHttp(unittest.TestCase): new_sub_label_response = client.post( f"/events/{id}/sub_label", json={"subLabel": sub_label}, + headers={"remote-role": "admin"}, ) assert new_sub_label_response.status_code == 200 event = client.get(f"/events/{id}").json() @@ -271,6 +272,7 @@ class TestHttp(unittest.TestCase): empty_sub_label_response = client.post( f"/events/{id}/sub_label", json={"subLabel": ""}, + headers={"remote-role": "admin"}, ) assert empty_sub_label_response.status_code == 200 event = client.get(f"/events/{id}").json() @@ -298,6 +300,7 @@ class TestHttp(unittest.TestCase): client.post( f"/events/{id}/sub_label", json={"subLabel": sub_label}, + headers={"remote-role": "admin"}, ) sub_labels = client.get("/sub_labels").json() assert sub_labels diff --git a/migrations/029_add_user_role.py b/migrations/029_add_user_role.py new file mode 100644 index 000000000..484e0c548 --- /dev/null +++ b/migrations/029_add_user_role.py @@ -0,0 +1,37 @@ +"""Peewee migrations -- 029_add_user_role.py. + +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 + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.sql( + 'ALTER TABLE "user" ADD COLUMN "role" VARCHAR(20) NOT NULL DEFAULT \'admin\'' + ) + migrator.sql('UPDATE "user" SET "role" = \'admin\' WHERE "role" IS NULL') + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.sql('ALTER TABLE "user" DROP COLUMN "role"') diff --git a/web/src/App.tsx b/web/src/App.tsx index ef0a9497e..a0062549f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,8 @@ import { Suspense, lazy } from "react"; import { Redirect } from "./components/navigation/Redirect"; import { cn } from "./lib/utils"; import { isPWA } from "./utils/isPWA"; +import ProtectedRoute from "@/components/auth/ProtectedRoute"; +import { AuthProvider } from "@/context/auth-context"; const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); @@ -21,45 +23,58 @@ const Settings = lazy(() => import("@/pages/Settings")); const UIPlayground = lazy(() => import("@/pages/UIPlayground")); const FaceLibrary = lazy(() => import("@/pages/FaceLibrary")); const Logs = lazy(() => import("@/pages/Logs")); +const AccessDenied = lazy(() => import("@/pages/AccessDenied")); function App() { return ( - - -
- {isDesktop && } - {isDesktop && } - {isMobile && } -
- - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + +
+ {isDesktop && } + {isDesktop && } + {isMobile && } +
+ + + + } + > + } /> + } /> + } /> + } /> + } /> + + } + > + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + + +
-
- - + + + ); } diff --git a/web/src/components/auth/AuthForm.tsx b/web/src/components/auth/AuthForm.tsx index 99ce37283..617ce1693 100644 --- a/web/src/components/auth/AuthForm.tsx +++ b/web/src/components/auth/AuthForm.tsx @@ -20,24 +20,23 @@ import { import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; +import { AuthContext } from "@/context/auth-context"; interface UserAuthFormProps extends React.HTMLAttributes {} export function UserAuthForm({ className, ...props }: UserAuthFormProps) { const [isLoading, setIsLoading] = React.useState(false); + const { login } = React.useContext(AuthContext); const formSchema = z.object({ - user: z.string(), - password: z.string(), + user: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", - defaultValues: { - user: "", - password: "", - }, + defaultValues: { user: "", password: "" }, }); const onSubmit = async (values: z.infer) => { @@ -50,11 +49,14 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { password: values.password, }, { - headers: { - "X-CSRF-TOKEN": 1, - }, + headers: { "X-CSRF-TOKEN": 1 }, }, ); + const profileRes = await axios.get("/profile", { withCredentials: true }); + login({ + username: profileRes.data.username, + role: profileRes.data.role || "viewer", + }); window.location.href = baseUrl; } catch (error) { if (axios.isAxiosError(error)) { diff --git a/web/src/components/auth/ProtectedRoute.tsx b/web/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 000000000..c35fdaebc --- /dev/null +++ b/web/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,40 @@ +import { useContext } from "react"; +import { Navigate, Outlet } from "react-router-dom"; +import { AuthContext } from "@/context/auth-context"; +import ActivityIndicator from "../indicators/activity-indicator"; + +export default function ProtectedRoute({ + requiredRoles, +}: { + requiredRoles: ("admin" | "viewer")[]; +}) { + const { auth } = useContext(AuthContext); + + if (auth.isLoading) { + return ( + + ); + } + + // Unauthenticated mode + if (!auth.isAuthenticated) { + return ; + } + + // Authenticated mode (8971): require login + if (!auth.user) { + return ; + } + + // If role is null (shouldn’t happen if isAuthenticated, but type safety), fallback + // though isAuthenticated should catch this + if (auth.user.role === null) { + return ; + } + + if (!requiredRoles.includes(auth.user.role)) { + return ; + } + + return ; +} diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 8aec2a117..cf6881056 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -281,10 +281,13 @@ function NewGroupDialog({ .catch((error) => { setOpen(false); setEditState("none"); - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); @@ -725,10 +728,13 @@ export function CameraGroupEdit({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/filter/SearchActionGroup.tsx b/web/src/components/filter/SearchActionGroup.tsx index aac03ad1c..32751a56f 100644 --- a/web/src/components/filter/SearchActionGroup.tsx +++ b/web/src/components/filter/SearchActionGroup.tsx @@ -44,8 +44,12 @@ export default function SearchActionGroup({ pullLatestData(); } }) - .catch(() => { - toast.error("Failed to delete tracked objects.", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete tracked objects.: ${errorMessage}`, { position: "top-center", }); }); diff --git a/web/src/components/menu/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx index 0bc968061..7e948308f 100644 --- a/web/src/components/menu/AccountSettings.tsx +++ b/web/src/components/menu/AccountSettings.tsx @@ -18,22 +18,52 @@ import { } from "../ui/dropdown-menu"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { DialogClose } from "../ui/dialog"; -import { LuLogOut } from "react-icons/lu"; +import { LuLogOut, LuSquarePen } from "react-icons/lu"; import useSWR from "swr"; +import { useState } from "react"; +import axios from "axios"; +import { toast } from "sonner"; +import SetPasswordDialog from "../overlay/SetPasswordDialog"; type AccountSettingsProps = { className?: string; }; + export default function AccountSettings({ className }: AccountSettingsProps) { const { data: profile } = useSWR("profile"); const { data: config } = useSWR("config"); const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`; + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const Container = isDesktop ? DropdownMenu : Drawer; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Content = isDesktop ? DropdownMenuContent : DrawerContent; const MenuItem = isDesktop ? DropdownMenuItem : DialogClose; + const handlePasswordSave = async (password: string) => { + if (!profile?.username || profile.username === "anonymous") return; + axios + .put(`users/${profile.username}/password`, { password }) + .then((response) => { + if (response.status === 200) { + setPasswordDialogOpen(false); + toast.success("Password updated successfully.", { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Error setting password: ${errorMessage}`, { + position: "top-center", + }); + }); + }; + return ( @@ -65,9 +95,22 @@ export default function AccountSettings({ className }: AccountSettingsProps) { >
- Current User: {profile?.username || "anonymous"} + Current User: {profile?.username || "anonymous"}{" "} + {profile?.role && `(${profile.role})`} + {profile?.username && profile.username !== "anonymous" && ( + setPasswordDialogOpen(true)} + > + + Set Password + + )}
+ setPasswordDialogOpen(false)} + username={profile?.username} + />
); } diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index c6f920461..b07ace2a3 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -24,7 +24,6 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "../ui/dropdown-menu"; - import { Link } from "react-router-dom"; import { CgDarkMode } from "react-icons/cg"; import { @@ -33,10 +32,8 @@ import { useTheme, } from "@/context/theme-provider"; import { IoColorPalette } from "react-icons/io5"; - import { useState } from "react"; import { useRestart } from "@/api/ws"; - import { Tooltip, TooltipContent, @@ -55,21 +52,27 @@ import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; import useSWR from "swr"; import RestartDialog from "../overlay/dialog/RestartDialog"; +import { useIsAdmin } from "@/hooks/use-is-admin"; +import SetPasswordDialog from "../overlay/SetPasswordDialog"; +import { toast } from "sonner"; +import axios from "axios"; type GeneralSettingsProps = { className?: string; }; + export default function GeneralSettings({ className }: GeneralSettingsProps) { const { data: profile } = useSWR("profile"); const { data: config } = useSWR("config"); const logoutUrl = config?.proxy?.logout_url || "/api/logout"; - // settings - const { theme, colorScheme, setTheme, setColorScheme } = useTheme(); const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); + const isAdmin = useIsAdmin(); + const Container = isDesktop ? DropdownMenu : Drawer; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Content = isDesktop ? DropdownMenuContent : DrawerContent; @@ -79,6 +82,29 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { const SubItemContent = isDesktop ? DropdownMenuSubContent : DialogContent; const Portal = isDesktop ? DropdownMenuPortal : DialogPortal; + const handlePasswordSave = async (password: string) => { + if (!profile?.username || profile.username === "anonymous") return; + axios + .put(`users/${profile.username}/password`, { password }) + .then((response) => { + if (response.status === 200) { + setPasswordDialogOpen(false); + toast.success("Password updated successfully.", { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Error setting password: ${errorMessage}`, { + position: "top-center", + }); + }); + }; + return ( <> @@ -121,13 +147,28 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { >
{isMobile && ( - <> +
- Current User: {profile?.username || "anonymous"} + Current User: {profile?.username || "anonymous"}{" "} + {profile?.role && `(${profile.role})`} + {profile?.username && profile.username !== "anonymous" && ( + setPasswordDialogOpen(true)} + > + + Set Password + + )} Logout +
+ )} + {isAdmin && ( + <> + System + + + + + + System metrics + + + + + + System logs + + + )} - System - - - - - - System metrics - - - - - - System logs - - - - + Configuration @@ -191,143 +238,143 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { Settings - - - - Configuration editor - - - - Appearance - - - - - - Dark Mode - - - - + {isAdmin && ( + <> + setTheme("light")} + aria-label="Configuration editor" > - {theme === "light" ? ( - <> - - Light - - ) : ( - Light - )} + + Configuration editor - setTheme("dark")} - > - {theme === "dark" ? ( - <> - - Dark - - ) : ( - Dark - )} - - setTheme("system")} - > - {theme === "system" ? ( - <> - - System - - ) : ( - System - )} - - - - - - - - Theme - - - - - {colorSchemes.map((scheme) => ( - setColorScheme(scheme)} - > - {scheme === colorScheme ? ( - <> - - {friendlyColorSchemeName(scheme)} - - ) : ( - - {friendlyColorSchemeName(scheme)} - - )} - - ))} - - - + + + )} + + Appearance + + + + + + Dark Mode + + + + + setTheme("light")} + > + {theme === "light" ? ( + <> + + Light + + ) : ( + Light + )} + + setTheme("dark")} + > + {theme === "dark" ? ( + <> + + Dark + + ) : ( + Dark + )} + + setTheme("system")} + > + {theme === "system" ? ( + <> + + System + + ) : ( + System + )} + + + + + + + + Theme + + + + + {colorSchemes.map((scheme) => ( + setColorScheme(scheme)} + > + {scheme === colorScheme ? ( + <> + + {friendlyColorSchemeName(scheme)} + + ) : ( + + {friendlyColorSchemeName(scheme)} + + )} + + ))} + + + Help @@ -357,17 +404,25 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { GitHub - - setRestartDialogOpen(true)} - > - - Restart Frigate - + {isAdmin && ( + <> + + setRestartDialogOpen(true)} + > + + Restart Frigate + + + )}
@@ -376,6 +431,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { onClose={() => setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} /> + setPasswordDialogOpen(false)} + username={profile?.username} + /> ); } diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 8db67e43e..4d1fd4966 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -74,8 +74,12 @@ export default function SearchResultActions({ refreshResults(); } }) - .catch(() => { - toast.error("Failed to delete tracked object.", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete tracked object: ${errorMessage}`, { position: "top-center", }); }); diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx index 7d44159dd..89403c37f 100644 --- a/web/src/components/overlay/CreateUserDialog.tsx +++ b/web/src/components/overlay/CreateUserDialog.tsx @@ -2,6 +2,7 @@ import { Button } from "../ui/button"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -12,20 +13,31 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import ActivityIndicator from "../indicators/activity-indicator"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Shield, User } from "lucide-react"; +import { LuCheck, LuX } from "react-icons/lu"; type CreateUserOverlayProps = { show: boolean; - onCreate: (user: string, password: string) => void; + onCreate: (user: string, password: string, role: "admin" | "viewer") => void; onCancel: () => void; }; + export default function CreateUserDialog({ show, onCreate, @@ -33,15 +45,22 @@ export default function CreateUserDialog({ }: CreateUserOverlayProps) { const [isLoading, setIsLoading] = useState(false); - const formSchema = z.object({ - user: z - .string() - .min(1) - .regex(/^[A-Za-z0-9._]+$/, { - message: "Username may only include letters, numbers, . or _", - }), - password: z.string(), - }); + const formSchema = z + .object({ + user: z + .string() + .min(1, "Username is required") + .regex(/^[A-Za-z0-9._]+$/, { + message: "Username may only include letters, numbers, . or _", + }), + password: z.string().min(1, "Password is required"), + confirmPassword: z.string().min(1, "Please confirm your password"), + role: z.enum(["admin", "viewer"]), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); const form = useForm>({ resolver: zodResolver(formSchema), @@ -49,32 +68,93 @@ export default function CreateUserDialog({ defaultValues: { user: "", password: "", + confirmPassword: "", + role: "viewer", }, }); const onSubmit = async (values: z.infer) => { setIsLoading(true); - await onCreate(values.user, values.password); + await onCreate(values.user, values.password, values.role); form.reset(); setIsLoading(false); }; + // Check if passwords match for real-time feedback + const password = form.watch("password"); + const confirmPassword = form.watch("confirmPassword"); + const passwordsMatch = password === confirmPassword; + const showMatchIndicator = password && confirmPassword; + + useEffect(() => { + if (!show) { + form.reset({ + user: "", + password: "", + role: "viewer", + }); + } + }, [show, form]); + + const handleCancel = () => { + form.reset({ + user: "", + password: "", + role: "viewer", + }); + onCancel(); + }; + return ( - + - Create User + Create New User + + Add a new user account and specify an role for access to areas of + the Frigate UI. + +
- + ( - User + + Username + + + + Only letters, numbers, periods and underscores allowed. + + + + )} + /> + + ( + + + Password + + + @@ -82,30 +162,121 @@ export default function CreateUserDialog({ )} /> + ( - Password + + Confirm Password + + {showMatchIndicator && ( +
+ {passwordsMatch ? ( + <> + + + Passwords match + + + ) : ( + <> + + + Passwords don't match + + + )} +
+ )} +
)} /> - - + + ( + + Role + + + 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. + + + + )} + /> + + +
+
+ + +
+
diff --git a/web/src/components/overlay/DeleteUserDialog.tsx b/web/src/components/overlay/DeleteUserDialog.tsx index 8638b9145..e8dfb79c1 100644 --- a/web/src/components/overlay/DeleteUserDialog.tsx +++ b/web/src/components/overlay/DeleteUserDialog.tsx @@ -6,34 +6,61 @@ import { DialogHeader, DialogTitle, } from "../ui/dialog"; +import { DialogDescription } from "@radix-ui/react-dialog"; -type SetPasswordProps = { +type DeleteUserDialogProps = { show: boolean; + username?: string; onDelete: () => void; onCancel: () => void; }; export default function DeleteUserDialog({ show, + username, onDelete, onCancel, -}: SetPasswordProps) { +}: DeleteUserDialogProps) { return ( - - - Delete User + + +
+ Delete User + + This action cannot be undone. This will permanently delete the + user account and remove all associated data. + +
-
Are you sure?
- - + +
+

+ Are you sure you want to delete{" "} + {username}? +

+
+ + +
+
+ + +
+
diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 00966e06a..4f49abaf0 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -99,16 +99,13 @@ export default function ExportDialog({ } }) .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", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to start export: ${errorMessage}`, { + position: "top-center", + }); }); }, [camera, name, range, setRange, setName, setMode]); diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 0a316acc7..81b1eefe9 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -106,16 +106,13 @@ export default function MobileReviewSettingsDrawer({ } }) .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", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to start export: ${errorMessage}`, { + position: "top-center", + }); }); }, [camera, name, range, setRange, setName, setMode]); diff --git a/web/src/components/overlay/RoleChangeDialog.tsx b/web/src/components/overlay/RoleChangeDialog.tsx new file mode 100644 index 000000000..577c748ff --- /dev/null +++ b/web/src/components/overlay/RoleChangeDialog.tsx @@ -0,0 +1,119 @@ +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { useState } from "react"; +import { LuShield, LuUser } from "react-icons/lu"; + +type RoleChangeDialogProps = { + show: boolean; + username: string; + currentRole: "admin" | "viewer"; + onSave: (role: "admin" | "viewer") => void; + onCancel: () => void; +}; + +export default function RoleChangeDialog({ + show, + username, + currentRole, + onSave, + onCancel, +}: RoleChangeDialogProps) { + const [selectedRole, setSelectedRole] = useState<"admin" | "viewer">( + currentRole, + ); + + return ( + + + + + Change User Role + + + Update permissions for{" "} + {username} + + + +
+
+

Select the appropriate role for this user:

+
    +
  • + • Admin: Full access to all + features. +
  • +
  • + • Viewer: Limited to Live + dashboards, Review, Explore, and Exports only. +
  • +
+
+ + +
+ + +
+
+ + +
+
+
+
+
+ ); +} diff --git a/web/src/components/overlay/SetPasswordDialog.tsx b/web/src/components/overlay/SetPasswordDialog.tsx index 2f6cc4eaf..108b568d7 100644 --- a/web/src/components/overlay/SetPasswordDialog.tsx +++ b/web/src/components/overlay/SetPasswordDialog.tsx @@ -1,50 +1,202 @@ +"use client"; + import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../ui/dialog"; +import { Label } from "../ui/label"; +import { LuCheck, LuX } from "react-icons/lu"; type SetPasswordProps = { show: boolean; onSave: (password: string) => void; onCancel: () => void; + username?: string; }; + export default function SetPasswordDialog({ show, onSave, onCancel, + username, }: SetPasswordProps) { - const [password, setPassword] = useState(); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordStrength, setPasswordStrength] = useState(0); + const [error, setError] = useState(null); + + // Reset state when dialog opens/closes + useEffect(() => { + if (show) { + setPassword(""); + setConfirmPassword(""); + setError(null); + } + }, [show]); + + // Simple password strength calculation + useEffect(() => { + if (!password) { + setPasswordStrength(0); + return; + } + + let strength = 0; + // Length check + if (password.length >= 8) strength += 1; + // Contains number + if (/\d/.test(password)) strength += 1; + // Contains special char + if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) strength += 1; + // Contains uppercase + if (/[A-Z]/.test(password)) strength += 1; + + setPasswordStrength(strength); + }, [password]); + + const handleSave = () => { + if (!password) { + setError("Password cannot be empty"); + return; + } + + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + onSave(password); + }; + + const getStrengthLabel = () => { + if (!password) return ""; + if (passwordStrength <= 1) return "Weak"; + if (passwordStrength === 2) return "Medium"; + if (passwordStrength === 3) return "Strong"; + return "Very Strong"; + }; + + const getStrengthColor = () => { + if (!password) return "bg-gray-200"; + if (passwordStrength <= 1) return "bg-red-500"; + if (passwordStrength === 2) return "bg-yellow-500"; + if (passwordStrength === 3) return "bg-green-500"; + return "bg-green-600"; + }; return ( - e.preventDefault()}> - - Set Password + + + + {username ? `Update Password for ${username}` : "Set Password"} + + + Create a strong password to secure this account. + - setPassword(event.target.value)} - /> - - + +
+
+ + { + setPassword(event.target.value); + setError(null); + }} + placeholder="Enter new password" + autoFocus + /> + + {/* Password strength indicator */} + {password && ( +
+
+
+
+

+ Password strength:{" "} + {getStrengthLabel()} +

+
+ )} +
+ +
+ + { + setConfirmPassword(event.target.value); + setError(null); + }} + placeholder="Confirm new password" + /> + + {/* Password match indicator */} + {password && confirmPassword && ( +
+ {password === confirmPassword ? ( + <> + + Passwords match + + ) : ( + <> + + Passwords don't match + + )} +
+ )} +
+ + {error && ( +
+ {error} +
+ )} +
+ + +
+
+ + +
+
diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx index 79d078c1f..df529c0dc 100644 --- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx +++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx @@ -87,10 +87,13 @@ export function AnnotationSettingsPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 9d3610e49..c94c2cd2d 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -394,8 +394,12 @@ function ObjectDetailsTab({ }, ); }) - .catch(() => { - toast.error("Failed to update the description", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update the description: ${errorMessage}`, { position: "top-center", }); setDesc(search.data.description); @@ -422,11 +426,13 @@ function ObjectDetailsTab({ } }) .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; toast.error( - `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${error.response.data.message}`, - { - position: "top-center", - }, + `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${errorMessage}`, + { position: "top-center" }, ); }); }, @@ -492,8 +498,12 @@ function ObjectDetailsTab({ setIsSubLabelDialogOpen(false); } }) - .catch(() => { - toast.error("Failed to update sub label.", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update sub label: ${errorMessage}`, { position: "top-center", }); }); diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx index 3b73c6a23..5c83f7720 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -176,10 +176,13 @@ export default function MotionMaskEditPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index 2c63d2e63..32e878c41 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -208,10 +208,13 @@ export default function ObjectMaskEditPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index 707df7a8f..db3f173a3 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -186,10 +186,13 @@ export default function PolygonItem({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index c6c5ee474..7adb3e194 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -414,10 +414,13 @@ export default function ZoneEditPane({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/context/auth-context.tsx b/web/src/context/auth-context.tsx new file mode 100644 index 000000000..a047d6fa3 --- /dev/null +++ b/web/src/context/auth-context.tsx @@ -0,0 +1,74 @@ +import axios from "axios"; +import { createContext, useEffect, useState } from "react"; +import useSWR from "swr"; + +interface AuthState { + user: { username: string; role: "admin" | "viewer" | null } | null; + isLoading: boolean; + isAuthenticated: boolean; // true if auth is required +} + +interface AuthContextType { + auth: AuthState; + login: (user: AuthState["user"]) => void; + logout: () => void; +} + +export const AuthContext = createContext({ + auth: { user: null, isLoading: true, isAuthenticated: false }, + login: () => {}, + logout: () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [auth, setAuth] = useState({ + user: null, + isLoading: true, + isAuthenticated: false, + }); + + const { data: profile, error } = useSWR("/profile", { + revalidateOnFocus: false, + revalidateOnReconnect: true, + fetcher: (url) => + axios.get(url, { withCredentials: true }).then((res) => res.data), + }); + + useEffect(() => { + if (error) { + if (axios.isAxiosError(error) && error.response?.status === 401) { + // auth required but not logged in + setAuth({ user: null, isLoading: false, isAuthenticated: true }); + } + return; + } + + if (profile) { + if (profile.username && profile.username !== "anonymous") { + const newUser = { + username: profile.username, + role: profile.role || "viewer", + }; + setAuth({ user: newUser, isLoading: false, isAuthenticated: true }); + } else { + // Unauthenticated mode (anonymous) + setAuth({ user: null, isLoading: false, isAuthenticated: false }); + } + } + }, [profile, error]); + + const login = (user: AuthState["user"]) => { + setAuth({ user, isLoading: false, isAuthenticated: true }); + }; + + const logout = () => { + setAuth({ user: null, isLoading: false, isAuthenticated: true }); + axios.get("/logout", { withCredentials: true }); + }; + + return ( + + {children} + + ); +} diff --git a/web/src/context/providers.tsx b/web/src/context/providers.tsx index 61b4a6426..b0a5f55c9 100644 --- a/web/src/context/providers.tsx +++ b/web/src/context/providers.tsx @@ -6,6 +6,7 @@ import { IconContext } from "react-icons"; import { TooltipProvider } from "@/components/ui/tooltip"; import { StatusBarMessagesProvider } from "@/context/statusbar-provider"; import { StreamingSettingsProvider } from "./streaming-settings-provider"; +import { AuthProvider } from "./auth-context"; type TProvidersProps = { children: ReactNode; @@ -14,19 +15,21 @@ type TProvidersProps = { function providers({ children }: TProvidersProps) { return ( - - - - - - - {children} - - - - - - + + + + + + + + {children} + + + + + + + ); } diff --git a/web/src/hooks/use-is-admin.ts b/web/src/hooks/use-is-admin.ts new file mode 100644 index 000000000..222a43fce --- /dev/null +++ b/web/src/hooks/use-is-admin.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { AuthContext } from "@/context/auth-context"; + +export function useIsAdmin() { + const { auth } = useContext(AuthContext); + const isAdmin = + (auth.isAuthenticated && auth.user?.role === "admin") || + auth.user?.role === undefined; + return isAdmin; +} diff --git a/web/src/pages/AccessDenied.tsx b/web/src/pages/AccessDenied.tsx new file mode 100644 index 000000000..53d83282b --- /dev/null +++ b/web/src/pages/AccessDenied.tsx @@ -0,0 +1,21 @@ +import Heading from "@/components/ui/heading"; +import { useEffect } from "react"; +import { FaExclamationTriangle } from "react-icons/fa"; + +export default function AccessDenied() { + useEffect(() => { + document.title = "Access Denied - Frigate"; + }, []); + + return ( +
+ + + Access Denied + +

+ You don't have permission to view this page. +

+
+ ); +} diff --git a/web/src/pages/ConfigEditor.tsx b/web/src/pages/ConfigEditor.tsx index bcb0c4c65..a8ca0eda3 100644 --- a/web/src/pages/ConfigEditor.tsx +++ b/web/src/pages/ConfigEditor.tsx @@ -59,11 +59,12 @@ function ConfigEditor() { .catch((error) => { toast.error("Error saving config", { position: "top-center" }); - if (error.response) { - setError(error.response.data.message); - } else { - setError(error.message); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + + setError(errorMessage); }); }, [editorRef], diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 529bb2e26..93cfa6b11 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -93,16 +93,13 @@ function Exports() { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to rename export: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to rename export: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to rename export: ${errorMessage}`, { + position: "top-center", + }); }); }, [mutate], diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index 8daf7e325..b9d3ee71a 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -99,16 +99,13 @@ export default function FaceLibrary() { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to upload image: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to upload image: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to upload image: ${errorMessage}`, { + position: "top-center", + }); }); }, [pageToggle, refreshFaces], @@ -132,16 +129,13 @@ export default function FaceLibrary() { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to set face name: ${error.response.data.message}`, - { position: "top-center" }, - ); - } else { - toast.error(`Failed to set face name: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to set face name: ${errorMessage}`, { + position: "top-center", + }); }); }, [refreshFaces], @@ -308,15 +302,13 @@ function FaceAttempt({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error(`Failed to train: ${error.response.data.message}`, { - position: "top-center", - }); - } else { - toast.error(`Failed to train: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to train: ${errorMessage}`, { + position: "top-center", + }); }); }, [image, onRefresh], @@ -334,18 +326,13 @@ function FaceAttempt({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error( - `Failed to update score: ${error.response.data.message}`, - { - position: "top-center", - }, - ); - } else { - toast.error(`Failed to update score: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update face score: ${errorMessage}`, { + position: "top-center", + }); }); }, [image, onRefresh]); @@ -361,15 +348,13 @@ function FaceAttempt({ } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error(`Failed to delete: ${error.response.data.message}`, { - position: "top-center", - }); - } else { - toast.error(`Failed to delete: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete: ${errorMessage}`, { + position: "top-center", + }); }); }, [image, onRefresh]); @@ -478,15 +463,13 @@ function FaceImage({ name, image, onRefresh }: FaceImageProps) { } }) .catch((error) => { - if (error.response?.data?.message) { - toast.error(`Failed to delete: ${error.response.data.message}`, { - position: "top-center", - }); - } else { - toast.error(`Failed to delete: ${error.message}`, { - position: "top-center", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete: ${errorMessage}`, { + position: "top-center", + }); }); }, [name, image, onRefresh]); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 33f854ba3..30be2afc2 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -40,6 +40,7 @@ import UiSettingsView from "@/views/settings/UiSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; +import { useIsAdmin } from "@/hooks/use-is-admin"; const allSettingsViews = [ "UI settings", @@ -62,6 +63,15 @@ export default function Settings() { const [searchParams] = useSearchParams(); + // auth and roles + + const isAdmin = useIsAdmin(); + + const allowedViewsForViewer: SettingsType[] = ["UI settings", "debug"]; + const visibleSettingsViews = !isAdmin + ? allowedViewsForViewer + : allSettingsViews; + // TODO: confirm leave page const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); @@ -149,7 +159,12 @@ export default function Settings() { useSearchEffect("page", (page: string) => { if (allSettingsViews.includes(page as SettingsType)) { - setPage(page as SettingsType); + // Restrict viewer to UI settings + if (!isAdmin && !["UI settings", "debug"].includes(page)) { + setPage("UI settings"); + } else { + setPage(page as SettingsType); + } } // don't clear url params if we're creating a new object mask return !searchParams.has("object_mask"); @@ -180,11 +195,16 @@ export default function Settings() { value={pageToggle} onValueChange={(value: SettingsType) => { if (value) { - setPageToggle(value); + // Restrict viewer navigation + if (!isAdmin && !["UI settings", "debug"].includes(value)) { + setPageToggle("UI settings"); + } else { + setPageToggle(value); + } } }} > - {Object.values(allSettingsViews).map((item) => ( + {visibleSettingsViews.map((item) => ( { - 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", - }); - } + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to start export: ${errorMessage}`, { + position: "top-center", + }); }); }, [reviewItems], diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index cacdc7c1d..15dea59d6 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -116,6 +116,7 @@ import { Switch } from "@/components/ui/switch"; import axios from "axios"; import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; +import { useIsAdmin } from "@/hooks/use-is-admin"; type LiveCameraViewProps = { config?: FrigateConfig; @@ -982,6 +983,10 @@ function FrigateCameraFeatures({ const { payload: autotrackingState, send: sendAutotracking } = useAutotrackingState(camera.name); + // roles + + const isAdmin = useIsAdmin(); + // manual event const recordingEventIdRef = useRef(null); @@ -1080,65 +1085,71 @@ function FrigateCameraFeatures({ if (isDesktop || isTablet) { return ( <> - sendEnabled(enabledState == "ON" ? "OFF" : "ON")} - disabled={false} - /> - sendDetect(detectState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - sendRecord(recordState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - {audioDetectEnabled && ( - sendAudio(audioState == "ON" ? "OFF" : "ON")} - disabled={!cameraEnabled} - /> - )} - {autotrackingEnabled && ( - - sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") - } - disabled={!cameraEnabled} - /> + {isAdmin && ( + <> + sendEnabled(enabledState == "ON" ? "OFF" : "ON")} + disabled={false} + /> + sendDetect(detectState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + sendRecord(recordState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + {audioDetectEnabled && ( + sendAudio(audioState == "ON" ? "OFF" : "ON")} + disabled={!cameraEnabled} + /> + )} + {autotrackingEnabled && ( + + sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") + } + disabled={!cameraEnabled} + /> + )} + )}
- - sendEnabled(enabledState == "ON" ? "OFF" : "ON") - } - /> - - sendDetect(detectState == "ON" ? "OFF" : "ON") - } - /> - {recordingEnabled && ( - - sendRecord(recordState == "ON" ? "OFF" : "ON") - } - /> - )} - - sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") - } - /> - {audioDetectEnabled && ( - - sendAudio(audioState == "ON" ? "OFF" : "ON") - } - /> - )} - {autotrackingEnabled && ( - - sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") - } - /> + {isAdmin && ( + <> + + sendEnabled(enabledState == "ON" ? "OFF" : "ON") + } + /> + + sendDetect(detectState == "ON" ? "OFF" : "ON") + } + /> + {recordingEnabled && ( + + sendRecord(recordState == "ON" ? "OFF" : "ON") + } + /> + )} + + sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") + } + /> + {audioDetectEnabled && ( + + sendAudio(audioState == "ON" ? "OFF" : "ON") + } + /> + )} + {autotrackingEnabled && ( + + sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") + } + /> + )} + )}
+
{!isRestreamed && (
diff --git a/web/src/views/settings/AuthenticationView.tsx b/web/src/views/settings/AuthenticationView.tsx index 1c6df5c52..118d102d4 100644 --- a/web/src/views/settings/AuthenticationView.tsx +++ b/web/src/views/settings/AuthenticationView.tsx @@ -11,10 +11,25 @@ import axios from "axios"; import CreateUserDialog from "@/components/overlay/CreateUserDialog"; import { toast } from "sonner"; import DeleteUserDialog from "@/components/overlay/DeleteUserDialog"; -import { Card } from "@/components/ui/card"; import { HiTrash } from "react-icons/hi"; import { FaUserEdit } from "react-icons/fa"; -import { LuPlus } from "react-icons/lu"; +import { LuPlus, LuShield, LuUserCog } from "react-icons/lu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import RoleChangeDialog from "@/components/overlay/RoleChangeDialog"; export default function AuthenticationView() { const { data: config } = useSWR("config"); @@ -23,8 +38,12 @@ export default function AuthenticationView() { const [showSetPassword, setShowSetPassword] = useState(false); const [showCreate, setShowCreate] = useState(false); const [showDelete, setShowDelete] = useState(false); + const [showRoleChange, setShowRoleChange] = useState(false); const [selectedUser, setSelectedUser] = useState(); + const [selectedUserRole, setSelectedUserRole] = useState< + "admin" | "viewer" + >(); useEffect(() => { document.title = "Authentication Settings - Frigate"; @@ -32,142 +51,303 @@ export default function AuthenticationView() { const onSavePassword = useCallback((user: string, password: string) => { axios - .put(`users/${user}/password`, { - password: password, - }) + .put(`users/${user}/password`, { password }) .then((response) => { - if (response.status == 200) { + if (response.status === 200) { setShowSetPassword(false); + toast.success("Password updated successfully", { + position: "top-center", + }); } }) - .catch((_error) => { - toast.error("Error setting password", { + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save password: ${errorMessage}`, { position: "top-center", }); }); }, []); - const onCreate = async (user: string, password: string) => { - try { - await axios.post("users", { - username: user, - password: password, + const onCreate = ( + user: string, + password: string, + role: "admin" | "viewer", + ) => { + axios + .post("users", { username: user, password, role }) + .then((response) => { + if (response.status === 200 || response.status === 201) { + setShowCreate(false); + mutateUsers((users) => { + users?.push({ username: user, role: role }); + return users; + }, false); + toast.success(`User ${user} created successfully`, { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to create user: ${errorMessage}`, { + position: "top-center", + }); }); - setShowCreate(false); - mutateUsers((users) => { - users?.push({ username: user }); - return users; - }, false); - } catch (error) { - toast.error("Error creating user. Check server logs.", { - position: "top-center", - }); - } }; - const onDelete = async (user: string) => { - try { - await axios.delete(`users/${user}`); - setShowDelete(false); - mutateUsers((users) => { - return users?.filter((u) => { - return u.username !== user; + const onDelete = (user: string) => { + axios + .delete(`users/${user}`) + .then((response) => { + if (response.status === 200) { + setShowDelete(false); + mutateUsers( + (users) => users?.filter((u) => u.username !== user), + false, + ); + toast.success(`User ${user} deleted successfully`, { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete user: ${errorMessage}`, { + position: "top-center", + }); + }); + }; + + const onChangeRole = (user: string, newRole: "admin" | "viewer") => { + if (user === "admin") return; // Prevent role change for 'admin' + + axios + .put(`users/${user}/role`, { role: newRole }) + .then((response) => { + if (response.status === 200) { + setShowRoleChange(false); + mutateUsers( + (users) => + users?.map((u) => + u.username === user ? { ...u, role: newRole } : u, + ), + false, + ); + toast.success(`Role updated for ${user}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to update role: ${errorMessage}`, { + position: "top-center", }); - }, false); - } catch (error) { - toast.error("Error deleting user. Check server logs.", { - position: "top-center", }); - } }; if (!config || !users) { - return ; + return ( +
+ +
+ ); } return (
-
- - Users - +
+
+ + User Management + +

+ Manage this Frigate instance's user accounts. +

+
-
- {users.map((u) => ( - -
-
- {u.username} -
-
- - -
-
-
- ))} +
+
+
+ + + + Username + Role + Actions + + + + {users.length === 0 ? ( + + + No users found. + + + ) : ( + users.map((user) => ( + + +
+ {user.username === "admin" ? ( + + ) : ( + + )} + {user.username} +
+
+ + + {user.role || "viewer"} + + + + +
+ {user.username !== "admin" && ( + + + + + +

Change user role

+
+
+ )} + + + + + + +

Update password

+
+
+ + {user.username !== "admin" && ( + + + + + +

Delete user

+
+
+ )} +
+
+
+
+ )) + )} +
+
+
+
+ { - setShowSetPassword(false); - }} - onSave={(password) => { - onSavePassword(selectedUser!, password); - }} + onCancel={() => setShowSetPassword(false)} + onSave={(password) => onSavePassword(selectedUser!, password)} /> { - setShowDelete(false); - }} - onDelete={() => { - onDelete(selectedUser!); - }} + username={selectedUser ?? "this user"} + onCancel={() => setShowDelete(false)} + onDelete={() => onDelete(selectedUser!)} /> { - setShowCreate(false); - }} + onCancel={() => setShowCreate(false)} /> + {selectedUser && selectedUserRole && ( + onChangeRole(selectedUser, role)} + onCancel={() => setShowRoleChange(false)} + /> + )}
); } diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index e2c1ca563..f83bdde50 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -171,10 +171,13 @@ export default function CameraSettingsView({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index d30de487e..a7dd1c9d4 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -267,10 +267,13 @@ export default function NotificationView({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/views/settings/SearchSettingsView.tsx b/web/src/views/settings/SearchSettingsView.tsx index 027f55070..b3f35bde7 100644 --- a/web/src/views/settings/SearchSettingsView.tsx +++ b/web/src/views/settings/SearchSettingsView.tsx @@ -103,10 +103,13 @@ export default function SearchSettingsView({ } }) .catch((error) => { - toast.error( - `Failed to save config changes: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); }) .finally(() => { setIsLoading(false); diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index e3b5c8c7a..03375670f 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -38,10 +38,13 @@ export default function UiSettingsView() { }); }) .catch((error) => { - toast.error( - `Failed to clear stored layout: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to clear stored layout: ${errorMessage}`, { + position: "top-center", + }); }); }); }, [config]); @@ -58,10 +61,13 @@ export default function UiSettingsView() { }); }) .catch((error) => { - toast.error( - `Failed to clear camera groups streaming settings: ${error.response.data.message}`, - { position: "top-center" }, - ); + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to clear streaming settings: ${errorMessage}`, { + position: "top-center", + }); }); }, [config]); From cf3c0b2eb5179fadc68417997f8d03eb695a7d03 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 8 Mar 2025 10:13:07 -0600 Subject: [PATCH 37/82] Prevent settings menu scroll on iOS proxy iframe from shifting entire UI (#17024) --- web/src/pages/Settings.tsx | 6 ++++-- web/src/utils/isIFrame.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 web/src/utils/isIFrame.ts diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 30be2afc2..f1a45ebc2 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -20,7 +20,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; -import { isMobile } from "react-device-detect"; +import { isMobile, isMobileSafari } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; @@ -40,6 +40,8 @@ import UiSettingsView from "@/views/settings/UiSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; import { useInitialCameraState } from "@/api/ws"; +import { isInIframe } from "@/utils/isIFrame"; +import { isPWA } from "@/utils/isPWA"; import { useIsAdmin } from "@/hooks/use-is-admin"; const allSettingsViews = [ @@ -150,7 +152,7 @@ export default function Settings() { ); if (element instanceof HTMLElement) { scrollIntoView(element, { - behavior: "smooth", + behavior: isMobileSafari && !isPWA && isInIframe ? "auto" : "smooth", inline: "start", }); } diff --git a/web/src/utils/isIFrame.ts b/web/src/utils/isIFrame.ts new file mode 100644 index 000000000..1f78a40aa --- /dev/null +++ b/web/src/utils/isIFrame.ts @@ -0,0 +1,8 @@ +export const isInIframe = (() => { + try { + return window.self !== window.top; + } catch (e) { + // If we get a security error, we're definitely in an iframe + return true; + } +})(); From 95b5854449647a931943e412b4c854c6f3cb0d1b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 9 Mar 2025 08:47:10 -0500 Subject: [PATCH 38/82] Small UI bugfix (#17035) * test for more HA elements * check if mobile and iOS instead of mobilesafari * simplify * fix for logs view --- web/src/pages/Logs.tsx | 6 +++++- web/src/pages/Settings.tsx | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index a4b67f441..196e6fdd7 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -31,6 +31,9 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { debounce } from "lodash"; +import { isIOS, isMobile } from "react-device-detect"; +import { isPWA } from "@/utils/isPWA"; +import { isInIframe } from "@/utils/isIFrame"; function Logs() { const [logService, setLogService] = useState("frigate"); @@ -54,7 +57,8 @@ function Logs() { ); if (element instanceof HTMLElement) { scrollIntoView(element, { - behavior: "smooth", + behavior: + isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth", inline: "start", }); } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index f1a45ebc2..bfc3f6f8e 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -20,7 +20,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; -import { isMobile, isMobileSafari } from "react-device-detect"; +import { isIOS, isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; @@ -152,7 +152,8 @@ export default function Settings() { ); if (element instanceof HTMLElement) { scrollIntoView(element, { - behavior: isMobileSafari && !isPWA && isInIframe ? "auto" : "smooth", + behavior: + isMobile && isIOS && !isPWA && isInIframe ? "auto" : "smooth", inline: "start", }); } From 1e0295fad5f8c32b76905b1e79d39e481ef75dd6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:58:31 -0500 Subject: [PATCH 39/82] LPR tweaks (#17046) --- .../common/license_plate/mixin.py | 129 +++++++++++------- 1 file changed, 78 insertions(+), 51 deletions(-) diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index aa03bc985..012924a1f 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -34,10 +34,10 @@ class LicensePlateProcessingMixin: self.batch_size = 6 # Detection specific parameters - self.min_size = 3 + self.min_size = 8 self.max_size = 960 - self.box_thresh = 0.8 - self.mask_thresh = 0.8 + self.box_thresh = 0.6 + self.mask_thresh = 0.6 def _detect(self, image: np.ndarray) -> List[np.ndarray]: """ @@ -158,47 +158,40 @@ class LicensePlateProcessingMixin: logger.debug("Model runners not loaded") return [], [], [] - plate_points = self._detect(image) - if len(plate_points) == 0: - logger.debug("No points found by OCR detector model") + boxes = self._detect(image) + if len(boxes) == 0: + logger.debug("No boxes found by OCR detector model") return [], [], [] - plate_points = self._sort_polygon(list(plate_points)) - plate_images = [self._crop_license_plate(image, x) for x in plate_points] - rotated_images, _ = self._classify(plate_images) + boxes = self._sort_boxes(list(boxes)) + plate_images = [self._crop_license_plate(image, x) for x in boxes] - # debug rotated and classification result if WRITE_DEBUG_IMAGES: current_time = int(datetime.datetime.now().timestamp()) for i, img in enumerate(plate_images): cv2.imwrite( - f"debug/frames/license_plate_rotated_{current_time}_{i + 1}.jpg", - img, - ) - for i, img in enumerate(rotated_images): - cv2.imwrite( - f"debug/frames/license_plate_classified_{current_time}_{i + 1}.jpg", + f"debug/frames/license_plate_cropped_{current_time}_{i + 1}.jpg", img, ) # keep track of the index of each image for correct area calc later - sorted_indices = np.argsort([x.shape[1] / x.shape[0] for x in rotated_images]) + sorted_indices = np.argsort([x.shape[1] / x.shape[0] for x in plate_images]) reverse_mapping = { idx: original_idx for original_idx, idx in enumerate(sorted_indices) } - results, confidences = self._recognize(rotated_images) + results, confidences = self._recognize(plate_images) if results: - license_plates = [""] * len(rotated_images) - average_confidences = [[0.0]] * len(rotated_images) - areas = [0] * len(rotated_images) + license_plates = [""] * len(plate_images) + average_confidences = [[0.0]] * len(plate_images) + areas = [0] * len(plate_images) # map results back to original image order for i, (plate, conf) in enumerate(zip(results, confidences)): original_idx = reverse_mapping[i] - height, width = rotated_images[original_idx].shape[:2] + height, width = plate_images[original_idx].shape[:2] area = height * width average_confidence = conf @@ -206,7 +199,7 @@ class LicensePlateProcessingMixin: # set to True to write each cropped image for debugging if False: save_image = cv2.cvtColor( - rotated_images[original_idx], cv2.COLOR_RGB2BGR + plate_images[original_idx], cv2.COLOR_RGB2BGR ) filename = f"debug/frames/plate_{original_idx}_{plate}_{area}.jpg" cv2.imwrite(filename, save_image) @@ -328,7 +321,7 @@ class LicensePlateProcessingMixin: # Use pyclipper to shrink the polygon slightly based on the computed distance. offset = PyclipperOffset() offset.AddPath(points, JT_ROUND, ET_CLOSEDPOLYGON) - points = np.array(offset.Execute(distance * 1.5)).reshape((-1, 1, 2)) + points = np.array(offset.Execute(distance * 1.75)).reshape((-1, 1, 2)) # get the minimum bounding box around the shrunken polygon. box, min_side = self._get_min_boxes(points) @@ -453,46 +446,64 @@ class LicensePlateProcessingMixin: ) @staticmethod - def _clockwise_order(point: np.ndarray) -> np.ndarray: + def _clockwise_order(pts: np.ndarray) -> np.ndarray: """ - Arrange the points of a polygon in clockwise order based on their angular positions - around the polygon's center. + Arrange the points of a polygon in order: top-left, top-right, bottom-right, bottom-left. + taken from https://github.com/PyImageSearch/imutils/blob/master/imutils/perspective.py Args: - point (np.ndarray): Array of points of the polygon. + pts (np.ndarray): Array of points of the polygon. Returns: - np.ndarray: Points ordered in clockwise direction. + np.ndarray: Points ordered clockwise starting from top-left. """ - center = point.mean(axis=0) - return point[ - np.argsort(np.arctan2(point[:, 1] - center[1], point[:, 0] - center[0])) - ] + # Sort the points based on their x-coordinates + x_sorted = pts[np.argsort(pts[:, 0]), :] + + # Separate the left-most and right-most points + left_most = x_sorted[:2, :] + right_most = x_sorted[2:, :] + + # Sort the left-most coordinates by y-coordinates + left_most = left_most[np.argsort(left_most[:, 1]), :] + (tl, bl) = left_most # Top-left and bottom-left + + # Use the top-left as an anchor to calculate distances to right points + # The further point will be the bottom-right + distances = np.sqrt( + ((tl[0] - right_most[:, 0]) ** 2) + ((tl[1] - right_most[:, 1]) ** 2) + ) + + # Sort right points by distance (descending) + right_idx = np.argsort(distances)[::-1] + (br, tr) = right_most[right_idx, :] # Bottom-right and top-right + + return np.array([tl, tr, br, bl]) @staticmethod - def _sort_polygon(points): + def _sort_boxes(boxes): """ - Sort polygons based on their position in the image. If polygons are close in vertical + Sort polygons based on their position in the image. If boxes are close in vertical position (within 5 pixels), sort them by horizontal position. Args: - points: List of polygons to sort. + points: detected text boxes with shape [4, 2] Returns: - List: Sorted list of polygons. + List: sorted boxes(array) with shape [4, 2] """ - points.sort(key=lambda x: (x[0][1], x[0][0])) - for i in range(len(points) - 1): + boxes.sort(key=lambda x: (x[0][1], x[0][0])) + for i in range(len(boxes) - 1): for j in range(i, -1, -1): - if abs(points[j + 1][0][1] - points[j][0][1]) < 5 and ( - points[j + 1][0][0] < points[j][0][0] + if abs(boxes[j + 1][0][1] - boxes[j][0][1]) < 5 and ( + boxes[j + 1][0][0] < boxes[j][0][0] ): - temp = points[j] - points[j] = points[j + 1] - points[j + 1] = temp + temp = boxes[j] + boxes[j] = boxes[j + 1] + boxes[j + 1] = temp else: break - return points + return boxes @staticmethod def _zero_pad(image: np.ndarray) -> np.ndarray: @@ -583,9 +594,11 @@ class LicensePlateProcessingMixin: for j in range(len(outputs)): label, score = outputs[j] results[indices[i + j]] = [label, score] - # make sure we have high confidence if we need to flip a box, this will be rare in lpr - if "180" in label and score >= 0.9: - images[indices[i + j]] = cv2.rotate(images[indices[i + j]], 1) + # make sure we have high confidence if we need to flip a box + if "180" in label and score >= 0.7: + images[indices[i + j]] = cv2.rotate( + images[indices[i + j]], cv2.ROTATE_180 + ) return images, results @@ -682,7 +695,7 @@ class LicensePlateProcessingMixin: ) height, width = image.shape[0:2] if height * 1.0 / width >= 1.5: - image = np.rot90(image, k=3) + image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE) return image def _detect_license_plate(self, input: np.ndarray) -> tuple[int, int, int, int]: @@ -942,9 +955,23 @@ class LicensePlateProcessingMixin: return license_plate_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) + + # Expand the license_plate_box by 30% + box_array = np.array(license_plate_box) + expansion = (box_array[2:] - box_array[:2]) * 0.30 + expanded_box = np.array( + [ + license_plate_box[0] - expansion[0], + license_plate_box[1] - expansion[1], + license_plate_box[2] + expansion[0], + license_plate_box[3] + expansion[1], + ] + ).clip(0, [license_plate_frame.shape[1], license_plate_frame.shape[0]] * 2) + + # Crop using the expanded box license_plate_frame = license_plate_frame[ - license_plate_box[1] : license_plate_box[3], - license_plate_box[0] : license_plate_box[2], + int(expanded_box[1]) : int(expanded_box[3]), + int(expanded_box[0]) : int(expanded_box[2]), ] # double the size of the license plate frame for better OCR From 9f9117c3181c1212806a27625410df1e84efdbfb Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 9 Mar 2025 22:59:07 -0500 Subject: [PATCH 40/82] Ensure admin is default role (#17044) --- frigate/app.py | 1 + frigate/models.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frigate/app.py b/frigate/app.py index cdb4877cc..a77533619 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -600,6 +600,7 @@ class FrigateApp: User.insert( { User.username: "admin", + User.role: "admin", User.password_hash: password_hash, User.notification_tokens: [], } diff --git a/frigate/models.py b/frigate/models.py index 26375432e..11b25b938 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -119,7 +119,7 @@ class User(Model): # type: ignore[misc] username = CharField(null=False, primary_key=True, max_length=30) role = CharField( max_length=20, - default="viewer", + default="admin", ) password_hash = CharField(null=False, max_length=120) notification_tokens = JSONField() From 1c6700c6886865370e568a5f3b2174274a986677 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 9 Mar 2025 23:01:18 -0500 Subject: [PATCH 41/82] Ensure audio listener is defined before trying to stop ffmpeg (#17045) --- frigate/events/audio.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frigate/events/audio.py b/frigate/events/audio.py index 505802b8c..1a4fdd144 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -351,7 +351,8 @@ class AudioEventMaintainer(threading.Thread): self.read_audio() - stop_ffmpeg(self.audio_listener, self.logger) + if self.audio_listener: + stop_ffmpeg(self.audio_listener, self.logger) self.logpipe.close() self.requestor.stop() self.config_subscriber.stop() From b72afb6895f8075c80081b7b7e54c9f7d04e0838 Mon Sep 17 00:00:00 2001 From: Hieu LE Date: Mon, 10 Mar 2025 19:54:55 +0700 Subject: [PATCH 42/82] Fix ffmpeg cannot start because of loading shared lib (#16846) * Fix #16845 Maybe after PR #16712 , ffmpeg build with JP6 seem broken with error `/usr/lib/ffmpeg/jetson/bin/ffmpeg: error while loading shared libraries: libavdevice.so.60: cannot open shared object file: No such file or directory` This PR fixes the issue * Adding new LD entry for ffmpeg new location * Update Dockerfile.arm64 * Move LD config to Dockerfile arm64 instead of detector --- docker/tensorrt/Dockerfile.arm64 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/tensorrt/Dockerfile.arm64 b/docker/tensorrt/Dockerfile.arm64 index 7a88a03a6..5d5d5d939 100644 --- a/docker/tensorrt/Dockerfile.arm64 +++ b/docker/tensorrt/Dockerfile.arm64 @@ -86,6 +86,9 @@ RUN apt-get -qq update \ libx264-163 libx265-199 libegl1 \ && rm -rf /var/lib/apt/lists/* +# Fixes "Error loading shared libs" +RUN mkdir -p /etc/ld.so.conf.d && echo /usr/lib/ffmpeg/jetson/lib/ > /etc/ld.so.conf.d/ffmpeg.conf + COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \ --mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \ From cb25bd4a882eea6af052fa5df1dba9afb8c2b938 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 10 Mar 2025 08:59:24 -0500 Subject: [PATCH 43/82] Auth role bugfixes (#17066) * get correct role from header map * fix profile endpoint --- frigate/api/auth.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 1752b19c9..2be26cc8a 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -259,7 +259,7 @@ def auth(request: Request): # pass the user header value from the upstream proxy if a mapping is specified # or use anonymous if none are specified user_header = proxy_config.header_map.user - role_header = proxy_config.header_map.get("role", "Remote-Role") + role_header = proxy_config.header_map.role success_response.headers["remote-user"] = ( request.headers.get(user_header, default="anonymous") if user_header @@ -359,14 +359,14 @@ def auth(request: Request): @router.get("/profile") def profile(request: Request): username = request.headers.get("remote-user", "anonymous") - if username != "anonymous": + role = request.headers.get("remote-role") + + if role is None and username != "anonymous": try: user = User.get_by_id(username) role = getattr(user, "role", "viewer") except DoesNotExist: role = "viewer" # Fallback if user deleted - else: - role = None return JSONResponse(content={"username": username, "role": role}) From 2be5225440fff96a4d04c0a22f7bbde355afc1f3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:00:35 -0500 Subject: [PATCH 44/82] More auth role fixes (#17067) * simplify check and handle comma separated roles * spacing --- frigate/api/auth.py | 17 +++++++++-------- web/src/components/auth/AuthForm.tsx | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 2be26cc8a..91ca5e729 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -265,11 +265,18 @@ def auth(request: Request): if user_header else "anonymous" ) - success_response.headers["remote-role"] = ( + role_header = proxy_config.header_map.role + role = ( request.headers.get(role_header, default="viewer") if role_header else "viewer" ) + + # if comma-separated with "admin", use "admin", else "viewer" + success_response.headers["remote-role"] = ( + "admin" if role and "admin" in role else "viewer" + ) + return success_response # now apply authentication @@ -359,14 +366,8 @@ def auth(request: Request): @router.get("/profile") def profile(request: Request): username = request.headers.get("remote-user", "anonymous") - role = request.headers.get("remote-role") + role = request.headers.get("remote-role", "viewer") - if role is None and username != "anonymous": - try: - user = User.get_by_id(username) - role = getattr(user, "role", "viewer") - except DoesNotExist: - role = "viewer" # Fallback if user deleted return JSONResponse(content={"username": username, "role": role}) diff --git a/web/src/components/auth/AuthForm.tsx b/web/src/components/auth/AuthForm.tsx index 617ce1693..85bd6bccb 100644 --- a/web/src/components/auth/AuthForm.tsx +++ b/web/src/components/auth/AuthForm.tsx @@ -87,7 +87,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { return (
- + ( From 7d44970f78e653ee1e70c8600363511aaf59e4bf Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 10 Mar 2025 09:01:52 -0600 Subject: [PATCH 45/82] Face multi select (#17068) * Implement multi select for face library * Clear list of selected * Add keyboard shortcut --- web/src/pages/FaceLibrary.tsx | 154 +++++++++++++++++++++-------- web/src/views/events/EventView.tsx | 7 +- 2 files changed, 117 insertions(+), 44 deletions(-) diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index b9d3ee71a..fbb75c681 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -19,6 +19,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { cn } from "@/lib/utils"; import { FrigateConfig } from "@/types/frigateConfig"; @@ -141,6 +142,73 @@ export default function FaceLibrary() { [refreshFaces], ); + // face multiselect + + const [selectedFaces, setSelectedFaces] = useState([]); + + const onClickFace = useCallback( + (imageId: string) => { + const index = selectedFaces.indexOf(imageId); + + if (index != -1) { + if (selectedFaces.length == 1) { + setSelectedFaces([]); + } else { + const copy = [ + ...selectedFaces.slice(0, index), + ...selectedFaces.slice(index + 1), + ]; + setSelectedFaces(copy); + } + } else { + const copy = [...selectedFaces]; + copy.push(imageId); + setSelectedFaces(copy); + } + }, + [selectedFaces, setSelectedFaces], + ); + + const onDelete = useCallback(() => { + axios + .post(`/faces/train/delete`, { ids: selectedFaces }) + .then((resp) => { + setSelectedFaces([]); + + if (resp.status == 200) { + toast.success(`Successfully deleted face.`, { + position: "top-center", + }); + refreshFaces(); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to delete: ${errorMessage}`, { + position: "top-center", + }); + }); + }, [selectedFaces, refreshFaces]); + + // keyboard + + useKeyboardListener(["a"], (key, modifiers) => { + if (modifiers.repeat || !modifiers.down) { + return; + } + + switch (key) { + case "a": + if (modifiers.ctrl) { + setSelectedFaces([...trainImages]); + } + break; + } + }); + if (!config) { return ; } @@ -210,16 +278,27 @@ export default function FaceLibrary() {
-
- - -
+ {selectedFaces?.length > 0 ? ( +
+ +
+ ) : ( +
+ + {pageToggle != "train" && ( + + )} +
+ )}
{pageToggle && (pageToggle == "train" ? ( @@ -227,6 +306,8 @@ export default function FaceLibrary() { config={config} attemptImages={trainImages} faceNames={faces} + selectedFaces={selectedFaces} + onClickFace={onClickFace} onRefresh={refreshFaces} /> ) : ( @@ -244,22 +325,28 @@ type TrainingGridProps = { config: FrigateConfig; attemptImages: string[]; faceNames: string[]; + selectedFaces: string[]; + onClickFace: (image: string) => void; onRefresh: () => void; }; function TrainingGrid({ config, attemptImages, faceNames, + selectedFaces, + onClickFace, onRefresh, }: TrainingGridProps) { return ( -
+
{attemptImages.map((image: string) => ( onClickFace(image)} onRefresh={onRefresh} /> ))} @@ -271,12 +358,16 @@ type FaceAttemptProps = { image: string; faceNames: string[]; threshold: number; + selected: boolean; + onClick: () => void; onRefresh: () => void; }; function FaceAttempt({ image, faceNames, threshold, + selected, + onClick, onRefresh, }: FaceAttemptProps) { const data = useMemo(() => { @@ -336,30 +427,16 @@ function FaceAttempt({ }); }, [image, onRefresh]); - const onDelete = useCallback(() => { - axios - .post(`/faces/train/delete`, { ids: [image] }) - .then((resp) => { - if (resp.status == 200) { - toast.success(`Successfully deleted face.`, { - position: "top-center", - }); - onRefresh(); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error(`Failed to delete: ${errorMessage}`, { - position: "top-center", - }); - }); - }, [image, onRefresh]); - return ( -
+
@@ -409,15 +486,6 @@ function FaceAttempt({ Reprocess Face - - - - - Delete Face Attempt -
diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 5ffeef8af..0c0cc2571 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -757,7 +757,12 @@ function DetectionReview({ />
); From 0cc5d66e5b85e2f67cdd61dfdb27d1ab1ce93953 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 10 Mar 2025 16:29:29 -0600 Subject: [PATCH 46/82] Refactor sub label api (#17079) * Use event metadata updater to handle sub label operations * Use event metadata publisher for sub label setting * Formatting * fix tests * Cleanup --- frigate/api/event.py | 50 +++++---------- frigate/app.py | 9 +-- frigate/comms/event_metadata_updater.py | 28 ++++----- .../common/license_plate/mixin.py | 26 +++----- frigate/data_processing/post/license_plate.py | 3 + frigate/data_processing/real_time/bird.py | 28 +++++---- frigate/data_processing/real_time/face.py | 29 ++++----- .../real_time/license_plate.py | 3 + frigate/embeddings/maintainer.py | 39 +++++++++--- frigate/object_processing.py | 62 +++++++++++++++++++ frigate/test/test_http.py | 22 ++++++- 11 files changed, 184 insertions(+), 115 deletions(-) diff --git a/frigate/api/event.py b/frigate/api/event.py index 100bdfd9e..b47fe23c5 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -40,6 +40,7 @@ from frigate.api.defs.response.event_response import ( ) from frigate.api.defs.response.generic_response import GenericResponse from frigate.api.defs.tags import Tags +from frigate.comms.event_metadata_updater import EventMetadataTypeEnum from frigate.const import CLIPS_DIR from frigate.embeddings import EmbeddingsContext from frigate.events.external import ExternalEventProcessor @@ -969,27 +970,16 @@ def set_sub_label( try: event: Event = Event.get(Event.id == event_id) except DoesNotExist: - if not body.camera: - return JSONResponse( - content=( - { - "success": False, - "message": "Event " - + event_id - + " not found and camera is not provided.", - } - ), - status_code=404, - ) - event = None if request.app.detected_frames_processor: - tracked_obj: TrackedObject = ( - request.app.detected_frames_processor.camera_states[ - event.camera if event else body.camera - ].tracked_objects.get(event_id) - ) + tracked_obj: TrackedObject = None + + for state in request.app.detected_frames_processor.camera_states.values(): + tracked_obj = state.tracked_objects.get(event_id) + + if tracked_obj is not None: + break else: tracked_obj = None @@ -1008,23 +998,9 @@ def set_sub_label( new_sub_label = None new_score = None - if tracked_obj: - tracked_obj.obj_data["sub_label"] = (new_sub_label, new_score) - - # update timeline items - Timeline.update( - data=Timeline.data.update({"sub_label": (new_sub_label, new_score)}) - ).where(Timeline.source_id == event_id).execute() - - if event: - event.sub_label = new_sub_label - data = event.data - if new_sub_label is None: - data["sub_label_score"] = None - elif new_score is not None: - data["sub_label_score"] = new_score - event.data = data - event.save() + request.app.event_metadata_updater.publish( + EventMetadataTypeEnum.sub_label, (event_id, new_sub_label, new_score) + ) return JSONResponse( content={ @@ -1105,7 +1081,9 @@ def regenerate_description( camera_config = request.app.frigate_config.cameras[event.camera] if camera_config.genai.enabled: - request.app.event_metadata_updater.publish((event.id, params.source)) + request.app.event_metadata_updater.publish( + EventMetadataTypeEnum.regenerate_description, (event.id, params.source) + ) return JSONResponse( content=( diff --git a/frigate/app.py b/frigate/app.py index a77533619..af675eaaf 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -20,10 +20,7 @@ from frigate.camera import CameraMetrics, PTZMetrics from frigate.comms.base_communicator import Communicator from frigate.comms.config_updater import ConfigPublisher from frigate.comms.dispatcher import Dispatcher -from frigate.comms.event_metadata_updater import ( - EventMetadataPublisher, - EventMetadataTypeEnum, -) +from frigate.comms.event_metadata_updater import EventMetadataPublisher from frigate.comms.inter_process import InterProcessCommunicator from frigate.comms.mqtt import MqttClient from frigate.comms.webpush import WebPushClient @@ -327,9 +324,7 @@ class FrigateApp: def init_inter_process_communicator(self) -> None: self.inter_process_communicator = InterProcessCommunicator() self.inter_config_updater = ConfigPublisher() - self.event_metadata_updater = EventMetadataPublisher( - EventMetadataTypeEnum.regenerate_description - ) + self.event_metadata_updater = EventMetadataPublisher() self.inter_zmq_proxy = ZmqProxy() def init_onvif(self) -> None: diff --git a/frigate/comms/event_metadata_updater.py b/frigate/comms/event_metadata_updater.py index 87e1889ce..f3301aef4 100644 --- a/frigate/comms/event_metadata_updater.py +++ b/frigate/comms/event_metadata_updater.py @@ -2,9 +2,6 @@ import logging from enum import Enum -from typing import Optional - -from frigate.events.types import RegenerateDescriptionEnum from .zmq_proxy import Publisher, Subscriber @@ -14,6 +11,7 @@ logger = logging.getLogger(__name__) class EventMetadataTypeEnum(str, Enum): all = "" regenerate_description = "regenerate_description" + sub_label = "sub_label" class EventMetadataPublisher(Publisher): @@ -21,12 +19,11 @@ class EventMetadataPublisher(Publisher): topic_base = "event_metadata/" - def __init__(self, topic: EventMetadataTypeEnum) -> None: - topic = topic.value - super().__init__(topic) + def __init__(self) -> None: + super().__init__() - def publish(self, payload: tuple[str, RegenerateDescriptionEnum]) -> None: - super().publish(payload) + def publish(self, topic: EventMetadataTypeEnum, payload: any) -> None: + super().publish(payload, topic.value) class EventMetadataSubscriber(Subscriber): @@ -35,17 +32,14 @@ class EventMetadataSubscriber(Subscriber): topic_base = "event_metadata/" def __init__(self, topic: EventMetadataTypeEnum) -> None: - topic = topic.value - super().__init__(topic) + super().__init__(topic.value) - def check_for_update( - self, timeout: float = 1 - ) -> Optional[tuple[EventMetadataTypeEnum, str, RegenerateDescriptionEnum]]: + def check_for_update(self, timeout: float = 1) -> tuple | None: return super().check_for_update(timeout) - def _return_object(self, topic: str, payload: any) -> any: + def _return_object(self, topic: str, payload: tuple) -> tuple: if payload is None: - return (None, None, None) + return (None, None) + topic = EventMetadataTypeEnum[topic[len(self.topic_base) :]] - event_id, source = payload - return (topic, event_id, RegenerateDescriptionEnum(source)) + return (topic, payload) diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index 012924a1f..c74949d9c 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -8,12 +8,11 @@ from typing import List, Optional, Tuple import cv2 import numpy as np -import requests from Levenshtein import distance from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset from shapely.geometry import Polygon -from frigate.const import FRIGATE_LOCALHOST +from frigate.comms.event_metadata_updater import EventMetadataTypeEnum from frigate.util.image import area logger = logging.getLogger(__name__) @@ -1059,22 +1058,15 @@ class LicensePlateProcessingMixin: ) # Send the result to the API - resp = requests.post( - f"{FRIGATE_LOCALHOST}/api/events/{id}/sub_label", - json={ - "camera": obj_data.get("camera"), - "subLabel": sub_label, - "subLabelScore": avg_confidence, - }, + self.sub_label_publisher.publish( + EventMetadataTypeEnum.sub_label, (id, sub_label, avg_confidence) ) - - if resp.status_code == 200: - self.detected_license_plates[id] = { - "plate": top_plate, - "char_confidences": top_char_confidences, - "area": top_area, - "obj_data": obj_data, - } + self.detected_license_plates[id] = { + "plate": top_plate, + "char_confidences": top_char_confidences, + "area": top_area, + "obj_data": obj_data, + } def handle_request(self, topic, request_data) -> dict[str, any] | None: return diff --git a/frigate/data_processing/post/license_plate.py b/frigate/data_processing/post/license_plate.py index 2c80418c7..e5c8a29a8 100644 --- a/frigate/data_processing/post/license_plate.py +++ b/frigate/data_processing/post/license_plate.py @@ -8,6 +8,7 @@ import numpy as np from peewee import DoesNotExist from frigate.comms.embeddings_updater import EmbeddingsRequestEnum +from frigate.comms.event_metadata_updater import EventMetadataPublisher from frigate.config import FrigateConfig from frigate.data_processing.common.license_plate.mixin import ( WRITE_DEBUG_IMAGES, @@ -30,6 +31,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): def __init__( self, config: FrigateConfig, + sub_label_publisher: EventMetadataPublisher, metrics: DataProcessorMetrics, model_runner: LicensePlateModelRunner, detected_license_plates: dict[str, dict[str, any]], @@ -38,6 +40,7 @@ class LicensePlatePostProcessor(LicensePlateProcessingMixin, PostProcessorApi): self.model_runner = model_runner self.lpr_config = config.lpr self.config = config + self.sub_label_publisher = sub_label_publisher super().__init__(config, metrics, model_runner) def process_data( diff --git a/frigate/data_processing/real_time/bird.py b/frigate/data_processing/real_time/bird.py index 01490d895..d942edf6f 100644 --- a/frigate/data_processing/real_time/bird.py +++ b/frigate/data_processing/real_time/bird.py @@ -5,10 +5,13 @@ import os import cv2 import numpy as np -import requests +from frigate.comms.event_metadata_updater import ( + EventMetadataPublisher, + EventMetadataTypeEnum, +) from frigate.config import FrigateConfig -from frigate.const import FRIGATE_LOCALHOST, MODEL_CACHE_DIR +from frigate.const import MODEL_CACHE_DIR from frigate.util.object import calculate_region from ..types import DataProcessorMetrics @@ -23,9 +26,15 @@ logger = logging.getLogger(__name__) class BirdRealTimeProcessor(RealTimeProcessorApi): - def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics): + def __init__( + self, + config: FrigateConfig, + sub_label_publisher: EventMetadataPublisher, + metrics: DataProcessorMetrics, + ): super().__init__(config, metrics) self.interpreter: Interpreter = None + self.sub_label_publisher = sub_label_publisher self.tensor_input_details: dict[str, any] = None self.tensor_output_details: dict[str, any] = None self.detected_birds: dict[str, float] = {} @@ -134,17 +143,10 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): logger.debug(f"Score {score} is worse than previous score {previous_score}") return - resp = requests.post( - f"{FRIGATE_LOCALHOST}/api/events/{obj_data['id']}/sub_label", - json={ - "camera": obj_data.get("camera"), - "subLabel": self.labelmap[best_id], - "subLabelScore": score, - }, + self.sub_label_publisher.publish( + EventMetadataTypeEnum.sub_label, (id, self.labelmap[best_id], score) ) - - if resp.status_code == 200: - self.detected_birds[obj_data["id"]] = score + self.detected_birds[obj_data["id"]] = score def handle_request(self, topic, request_data): return None diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index e7cf622e9..c88228651 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -11,11 +11,14 @@ from typing import Optional import cv2 import numpy as np -import requests from frigate.comms.embeddings_updater import EmbeddingsRequestEnum +from frigate.comms.event_metadata_updater import ( + EventMetadataPublisher, + EventMetadataTypeEnum, +) from frigate.config import FrigateConfig -from frigate.const import FACE_DIR, FRIGATE_LOCALHOST, MODEL_CACHE_DIR +from frigate.const import FACE_DIR, MODEL_CACHE_DIR from frigate.util.image import area from ..types import DataProcessorMetrics @@ -28,9 +31,15 @@ MIN_MATCHING_FACES = 2 class FaceRealTimeProcessor(RealTimeProcessorApi): - def __init__(self, config: FrigateConfig, metrics: DataProcessorMetrics): + def __init__( + self, + config: FrigateConfig, + sub_label_publisher: EventMetadataPublisher, + metrics: DataProcessorMetrics, + ): super().__init__(config, metrics) self.face_config = config.face_recognition + self.sub_label_publisher = sub_label_publisher self.face_detector: cv2.FaceDetectorYN = None self.landmark_detector: cv2.face.FacemarkLBF = None self.recognizer: cv2.face.LBPHFaceRecognizer = None @@ -349,18 +358,10 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): self.__update_metrics(datetime.datetime.now().timestamp() - start) return - resp = requests.post( - f"{FRIGATE_LOCALHOST}/api/events/{id}/sub_label", - json={ - "camera": obj_data.get("camera"), - "subLabel": sub_label, - "subLabelScore": score, - }, + self.sub_label_publisher.publish( + EventMetadataTypeEnum.sub_label, (id, sub_label, score) ) - - if resp.status_code == 200: - self.detected_faces[id] = face_score - + self.detected_faces[id] = face_score self.__update_metrics(datetime.datetime.now().timestamp() - start) def handle_request(self, topic, request_data) -> dict[str, any] | None: diff --git a/frigate/data_processing/real_time/license_plate.py b/frigate/data_processing/real_time/license_plate.py index c8f0efa11..d2cb9f2a5 100644 --- a/frigate/data_processing/real_time/license_plate.py +++ b/frigate/data_processing/real_time/license_plate.py @@ -4,6 +4,7 @@ import logging import numpy as np +from frigate.comms.event_metadata_updater import EventMetadataPublisher from frigate.config import FrigateConfig from frigate.data_processing.common.license_plate.mixin import ( LicensePlateProcessingMixin, @@ -22,6 +23,7 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess def __init__( self, config: FrigateConfig, + sub_label_publisher: EventMetadataPublisher, metrics: DataProcessorMetrics, model_runner: LicensePlateModelRunner, detected_license_plates: dict[str, dict[str, any]], @@ -30,6 +32,7 @@ class LicensePlateRealTimeProcessor(LicensePlateProcessingMixin, RealTimeProcess self.model_runner = model_runner self.lpr_config = config.lpr self.config = config + self.sub_label_publisher = sub_label_publisher super().__init__(config, metrics) def process_frame(self, obj_data: dict[str, any], frame: np.ndarray): diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index b3bd6c204..2fa3eeb2c 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -15,6 +15,7 @@ from playhouse.sqliteq import SqliteQueueDatabase from frigate.comms.embeddings_updater import EmbeddingsRequestEnum, EmbeddingsResponder from frigate.comms.event_metadata_updater import ( + EventMetadataPublisher, EventMetadataSubscriber, EventMetadataTypeEnum, ) @@ -43,7 +44,7 @@ from frigate.data_processing.real_time.license_plate import ( LicensePlateRealTimeProcessor, ) from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum -from frigate.events.types import EventTypeEnum +from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum from frigate.genai import get_genai_client from frigate.models import Event from frigate.types import TrackedObjectUpdateTypesEnum @@ -89,6 +90,7 @@ class EmbeddingMaintainer(threading.Thread): self.event_subscriber = EventUpdateSubscriber() self.event_end_subscriber = EventEndSubscriber() + self.event_metadata_publisher = EventMetadataPublisher() self.event_metadata_subscriber = EventMetadataSubscriber( EventMetadataTypeEnum.regenerate_description ) @@ -108,15 +110,27 @@ class EmbeddingMaintainer(threading.Thread): self.realtime_processors: list[RealTimeProcessorApi] = [] if self.config.face_recognition.enabled: - self.realtime_processors.append(FaceRealTimeProcessor(self.config, metrics)) + self.realtime_processors.append( + FaceRealTimeProcessor( + self.config, self.event_metadata_publisher, metrics + ) + ) if self.config.classification.bird.enabled: - self.realtime_processors.append(BirdRealTimeProcessor(self.config, metrics)) + self.realtime_processors.append( + BirdRealTimeProcessor( + self.config, self.event_metadata_publisher, metrics + ) + ) if self.config.lpr.enabled: self.realtime_processors.append( LicensePlateRealTimeProcessor( - self.config, metrics, lpr_model_runner, self.detected_license_plates + self.config, + self.event_metadata_publisher, + metrics, + lpr_model_runner, + self.detected_license_plates, ) ) @@ -126,7 +140,11 @@ class EmbeddingMaintainer(threading.Thread): if self.config.lpr.enabled: self.post_processors.append( LicensePlatePostProcessor( - self.config, metrics, lpr_model_runner, self.detected_license_plates + self.config, + self.event_metadata_publisher, + metrics, + lpr_model_runner, + self.detected_license_plates, ) ) @@ -150,6 +168,7 @@ class EmbeddingMaintainer(threading.Thread): self.event_subscriber.stop() self.event_end_subscriber.stop() self.recordings_subscriber.stop() + self.event_metadata_publisher.stop() self.event_metadata_subscriber.stop() self.embeddings_responder.stop() self.requestor.stop() @@ -375,15 +394,17 @@ class EmbeddingMaintainer(threading.Thread): def _process_event_metadata(self): # Check for regenerate description requests - (topic, event_id, source) = self.event_metadata_subscriber.check_for_update( - timeout=0.01 - ) + (topic, payload) = self.event_metadata_subscriber.check_for_update(timeout=0.01) if topic is None: return + event_id, source = payload + if event_id: - self.handle_regenerate_description(event_id, source) + self.handle_regenerate_description( + event_id, RegenerateDescriptionEnum(source) + ) def _create_thumbnail(self, yuv_frame, box, height=500) -> Optional[bytes]: """Return jpg thumbnail of a region of the frame.""" diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 8faf91cb5..d31ca83e1 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -9,10 +9,15 @@ from typing import Callable, Optional import cv2 import numpy as np +from peewee import DoesNotExist from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.dispatcher import Dispatcher +from frigate.comms.event_metadata_updater import ( + EventMetadataSubscriber, + EventMetadataTypeEnum, +) from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher from frigate.comms.inter_process import InterProcessRequestor from frigate.config import ( @@ -24,6 +29,7 @@ from frigate.config import ( ) from frigate.const import UPDATE_CAMERA_ACTIVITY from frigate.events.types import EventStateEnum, EventTypeEnum +from frigate.models import Event, Timeline from frigate.ptz.autotrack import PtzAutoTrackerThread from frigate.track.tracked_object import TrackedObject from frigate.util.image import ( @@ -446,6 +452,9 @@ class TrackedObjectProcessor(threading.Thread): self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) self.event_sender = EventUpdatePublisher() self.event_end_subscriber = EventEndSubscriber() + self.sub_label_subscriber = EventMetadataSubscriber( + EventMetadataTypeEnum.sub_label + ) self.camera_activity: dict[str, dict[str, any]] = {} @@ -684,6 +693,46 @@ class TrackedObjectProcessor(threading.Thread): """Returns the latest frame time for a given camera.""" return self.camera_states[camera].current_frame_time + def set_sub_label( + self, event_id: str, sub_label: str | None, score: float | None + ) -> None: + """Update sub label for given event id.""" + tracked_obj: TrackedObject = None + + for state in self.camera_states.values(): + tracked_obj = state.tracked_objects.get(event_id) + + if tracked_obj is not None: + break + + try: + event: Event = Event.get(Event.id == event_id) + except DoesNotExist: + event = None + + if not tracked_obj and not event: + return + + if tracked_obj: + tracked_obj.obj_data["sub_label"] = (sub_label, score) + + if event: + event.sub_label = sub_label + data = event.data + if sub_label is None: + data["sub_label_score"] = None + elif score is not None: + data["sub_label_score"] = score + event.data = data + event.save() + + # update timeline items + Timeline.update( + data=Timeline.data.update({"sub_label": (sub_label, score)}) + ).where(Timeline.source_id == event_id).execute() + + return True + def force_end_all_events(self, camera: str, camera_state: CameraState): """Ends all active events on camera when disabling.""" last_frame_name = camera_state.previous_frame_id @@ -741,6 +790,18 @@ class TrackedObjectProcessor(threading.Thread): if not current_enabled: continue + # check for sub label updates + while True: + (topic, payload) = self.sub_label_subscriber.check_for_update( + timeout=0.1 + ) + + if not topic: + break + + (event_id, sub_label, score) = payload + self.set_sub_label(event_id, sub_label, score) + try: ( camera, @@ -799,6 +860,7 @@ class TrackedObjectProcessor(threading.Thread): self.detection_publisher.stop() self.event_sender.stop() self.event_end_subscriber.stop() + self.sub_label_subscriber.stop() self.config_enabled_subscriber.stop() logger.info("Exiting object processor...") diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index d6ff91a83..d23727672 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -2,6 +2,7 @@ import datetime import logging import os import unittest +from unittest.mock import Mock from fastapi.testclient import TestClient from peewee_migrate import Router @@ -10,6 +11,7 @@ from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqliteq import SqliteQueueDatabase from frigate.api.fastapi_app import create_fastapi_app +from frigate.comms.event_metadata_updater import EventMetadataPublisher from frigate.config import FrigateConfig from frigate.const import BASE_DIR, CACHE_DIR from frigate.models import Event, Recordings, Timeline @@ -243,6 +245,7 @@ class TestHttp(unittest.TestCase): assert len(events) == 1 def test_set_delete_sub_label(self): + mock_event_updater = Mock(spec=EventMetadataPublisher) app = create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, @@ -252,11 +255,18 @@ class TestHttp(unittest.TestCase): None, None, None, - None, + mock_event_updater, ) id = "123456.random" sub_label = "sub" + def update_event(topic, payload): + event = Event.get(id=id) + event.sub_label = payload[1] + event.save() + + mock_event_updater.publish.side_effect = update_event + with TestClient(app) as client: _insert_mock_event(id) new_sub_label_response = client.post( @@ -281,6 +291,7 @@ class TestHttp(unittest.TestCase): assert event["sub_label"] == None def test_sub_label_list(self): + mock_event_updater = Mock(spec=EventMetadataPublisher) app = create_fastapi_app( FrigateConfig(**self.minimal_config), self.db, @@ -290,11 +301,18 @@ class TestHttp(unittest.TestCase): None, None, None, - None, + mock_event_updater, ) id = "123456.random" sub_label = "sub" + def update_event(topic, payload): + event = Event.get(id=id) + event.sub_label = payload[1] + event.save() + + mock_event_updater.publish.side_effect = update_event + with TestClient(app) as client: _insert_mock_event(id) client.post( From bea6cf29c227abb520f33cccea94a4dcc6247819 Mon Sep 17 00:00:00 2001 From: MkSavin Date: Tue, 11 Mar 2025 01:36:43 +0300 Subject: [PATCH 47/82] fix(auth): Added trimming to jwt secret token read from .jwt_secret (#16467) Added cleaning of leading and trailing spaces and special characters from a line when reading a secret token from a `.jwt_secret` file --- frigate/api/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index 91ca5e729..c0ed94d5c 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -136,7 +136,7 @@ def get_jwt_secret() -> str: logger.debug("Using jwt secret from .jwt_secret file in config directory.") with open(jwt_secret_file) as f: try: - jwt_secret = f.readline() + jwt_secret = f.readline().strip() except Exception: logger.warning( "Unable to read jwt token from .jwt_secret file in config directory. A new jwt token will be created at each startup." From c43092da9a44be83925cc057ec2d37340a880f57 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 11 Mar 2025 06:57:00 -0600 Subject: [PATCH 48/82] Add self return for pydantic (#17091) --- frigate/config/logger.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frigate/config/logger.py b/frigate/config/logger.py index 45b8a3abe..e6e1c06d3 100644 --- a/frigate/config/logger.py +++ b/frigate/config/logger.py @@ -37,3 +37,5 @@ class LoggerConfig(FrigateBaseModel): for log, level in log_levels.items(): logging.getLogger(log).setLevel(level.value.upper()) + + return self From c9382f8969990e6406f6fe6c6099843e0b280bb9 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:05:42 -0500 Subject: [PATCH 49/82] Add docs for user roles (#17093) * Add docs for user roles * header mapping docs --- docs/docs/configuration/authentication.md | 52 +++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index dba9360ac..36994381d 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -97,15 +97,35 @@ python3 -c 'import secrets; print(secrets.token_hex(64))' ### Header mapping -If you have disabled Frigate's authentication and your proxy supports passing a header with the authenticated username, you can use the `header_map` config to specify the header name so it is passed to Frigate. For example, the following will map the `X-Forwarded-User` value. Header names are not case sensitive. +If you have disabled Frigate's authentication and your proxy supports passing a header with authenticated usernames and/or roles, you can use the `header_map` config to specify the header name so it is passed to Frigate. For example, the following will map the `X-Forwarded-User` and `X-Forwarded-Role` values. Header names are not case sensitive. ```yaml proxy: ... header_map: user: x-forwarded-user + role: x-forwarded-role ``` +Frigate supports both `admin` and `viewer` roles (see below). When using port `8971`, Frigate validates these headers and subsequent requests use the headers `remote-user` and `remote-role` for authorization. + +#### Port Considerations + +**Authenticated Port (8971)** + +- Header mapping is **fully supported**. +- The `remote-role` header determines the user’s privileges: + - **admin** → Full access (user management, configuration changes). + - **viewer** → Read-only access. +- Ensure your **proxy sends both user and role headers** for proper role enforcement. + +**Unauthenticated Port (5000)** + +- Headers are **ignored** for role enforcement. +- All requests are treated as **anonymous**. +- The `remote-role` value is **overridden** to **admin-level access**. +- This design ensures **unauthenticated internal use** within a trusted network. + Note that only the following list of headers are permitted by default: ``` @@ -126,8 +146,6 @@ X-authentik-uid If you would like to add more options, you can overwrite the default file with a docker bind mount at `/usr/local/nginx/conf/proxy_trusted_headers.conf`. Reference the source code for the default file formatting. -Future versions of Frigate may leverage group and role headers for authorization in Frigate as well. - ### Login page redirection Frigate gracefully performs login page redirection that should work with most authentication proxies. If your reverse proxy returns a `Location` header on `401`, `302`, or `307` unauthorized responses, Frigate's frontend will automatically detect it and redirect to that URL. @@ -135,3 +153,31 @@ Frigate gracefully performs login page redirection that should work with most au ### Custom logout url If your reverse proxy has a dedicated logout url, you can specify using the `logout_url` config option. This will update the link for the `Logout` link in the UI. + +## User Roles + +Frigate supports user roles to control access to certain features in the UI and API, such as managing users or modifying configuration settings. Roles are assigned to users in the database or through proxy headers and are enforced when accessing the UI or API through the authenticated port (`8971`). + +### Supported Roles + +- **admin**: Full access to all features, including user management and configuration. +- **viewer**: Read-only access to the UI and API, including viewing cameras, review items, and historical footage. Configuration editor and settings in the UI are inaccessible. + +### Role Enforcement + +When using the authenticated port (`8971`), roles are validated via the JWT token or proxy headers (e.g., `remote-role`). + +On the internal **unauthenticated** port (`5000`), roles are **not enforced**. All requests are treated as **anonymous**, granting access equivalent to the **admin** role without restrictions. + +To use role-based access control, you must connect to Frigate via the **authenticated port (`8971`)** directly or through a reverse proxy. + +### Role Visibility in the UI + +- When logged in via port `8971`, your **username and role** are displayed in the **account menu** (bottom corner). +- When using port `5000`, the UI will always display "anonymous" for the username and "admin" for the role. + +### Managing User Roles + +1. Log in as an **admin** user via port `8971`. +2. Navigate to **Settings > Users**. +3. Edit a user’s role by selecting **admin** or **viewer**. From 300f85720ccf551d8b5e5716bef1c52c9a7e7583 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 11 Mar 2025 13:18:43 -0600 Subject: [PATCH 50/82] Face blur factor (#17099) * Add option to apply factor to face blurring * Adjust blur factors * Add debug log --- frigate/config/classification.py | 3 +++ frigate/data_processing/real_time/face.py | 25 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/frigate/config/classification.py b/frigate/config/classification.py index f3416b009..07d986d7d 100644 --- a/frigate/config/classification.py +++ b/frigate/config/classification.py @@ -67,6 +67,9 @@ class FaceRecognitionConfig(FrigateBaseModel): save_attempts: bool = Field( default=True, title="Save images of face detections for training." ) + blur_confidence_filter: bool = Field( + default=True, title="Apply blur quality filter to face confidence." + ) class LicensePlateRecognitionConfig(FrigateBaseModel): diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index c88228651..c88aae027 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -192,6 +192,22 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): image, M, (output_width, output_height), flags=cv2.INTER_CUBIC ) + def __get_blur_factor(self, input: np.ndarray) -> float: + """Calculates the factor for the confidence based on the blur of the image.""" + if not self.face_config.blur_confidence_filter: + return 1.0 + + variance = cv2.Laplacian(input, cv2.CV_64F).var() + + if variance < 60: # image is very blurry + return 0.96 + elif variance < 70: # image moderately blurry + return 0.98 + elif variance < 80: # image is slightly blurry + return 0.99 + else: + return 1.0 + def __clear_classifier(self) -> None: self.face_recognizer = None self.label_map = {} @@ -232,14 +248,21 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): if not self.recognizer: return None + # face recognition is best run on grayscale images img = cv2.cvtColor(face_image, cv2.COLOR_BGR2GRAY) + + # get blur factor before aligning face + blur_factor = self.__get_blur_factor(img) + logger.debug(f"face detected with bluriness {blur_factor}") + + # align face and run recognition img = self.__align_face(img, img.shape[1], img.shape[0]) index, distance = self.recognizer.predict(img) if index == -1: return None - score = 1.0 - (distance / 1000) + score = (1.0 - (distance / 1000)) * blur_factor return self.label_map[index], round(score, 2) def __update_metrics(self, duration: float) -> None: From 7411a8bafa9cc0b8acf79ce97c9ce39c2779fe17 Mon Sep 17 00:00:00 2001 From: OmriAx <159430476+OmriAx@users.noreply.github.com> Date: Tue, 11 Mar 2025 21:36:07 +0200 Subject: [PATCH 51/82] Hailo Official integration (#16906) * Adding Models * Final Async Update * Bug Fixing * Fix * Adding fixes * Working async infer * Final Documenatation and debug update * Removing some extra prints * Post-process correct label push * config docs fix * Review Fix * Review fix 2.0 * Fixing the ASYNC API to work from 30ms to 10ms * Fix for multi stream async infernce * Format * Fix #3 * Format#2 * Remove Unnessery includes * Sort Imports --- docs/docs/configuration/object_detectors.md | 94 ++- docs/docs/frigate/hardware.md | 17 +- docs/docs/frigate/installation.md | 4 +- frigate/detectors/detector_config.py | 1 + frigate/detectors/plugins/hailo8l.py | 616 +++++++++++++------- 5 files changed, 495 insertions(+), 237 deletions(-) mode change 100644 => 100755 frigate/detectors/plugins/hailo8l.py diff --git a/docs/docs/configuration/object_detectors.md b/docs/docs/configuration/object_detectors.md index 3423ac3c6..a4f4c7c20 100644 --- a/docs/docs/configuration/object_detectors.md +++ b/docs/docs/configuration/object_detectors.md @@ -12,7 +12,7 @@ Frigate supports multiple different detectors that work on different types of ha **Most Hardware** - [Coral EdgeTPU](#edge-tpu-detector): The Google Coral EdgeTPU is available in USB and m.2 format allowing for a wide range of compatibility with devices. -- [Hailo](#hailo-8l): The Hailo8 AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices. +- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices, offering a wide range of compatibility with devices. **AMD** @@ -129,15 +129,58 @@ detectors: type: edgetpu device: pci ``` +--- -## Hailo-8l -This detector is available for use with Hailo-8 AI Acceleration Module. +## Hailo-8 -See the [installation docs](../frigate/installation.md#hailo-8l) for information on configuring the hailo8. +This detector is available for use with both Hailo-8 and Hailo-8L AI Acceleration Modules. The integration automatically detects your hardware architecture via the Hailo CLI and selects the appropriate default model if no custom model is specified. + +See the [installation docs](../frigate/installation.md#hailo-8l) for information on configuring the Hailo hardware. ### Configuration +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`. + +#### 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: +- **Hailo-8 hardware:** Uses **YOLOv6n** (default: `yolov6n.hef`) +- **Hailo-8L hardware:** Uses **YOLOv6n** (default: `yolov6n.hef`) + +```yaml +detectors: + hailo8l: + type: hailo8l + device: PCIe + +model: + width: 320 + height: 320 + input_tensor: nhwc + input_pixel_format: rgb + input_dtype: int + model_type: yolo-generic + + # The detector automatically selects the default model based on your hardware: + # - For Hailo-8 hardware: YOLOv6n (default: yolov6n.hef) + # - For Hailo-8L hardware: YOLOv6n (default: yolov6n.hef) + # + # Optionally, you can specify a local model path to override the default. + # If a local path is provided and the file exists, it will be used instead of downloading. + # Example: + # path: /config/model_cache/hailo/yolov6n.hef + # + # You can also override using a custom URL: + # path: https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8/yolov6n.hef + # just make sure to give it the write configuration based on the model +``` + +#### SSD + +For SSD-based models, provide either a model path or URL to your compiled SSD model. The integration will first check the local path before downloading if necessary. + ```yaml detectors: hailo8l: @@ -148,11 +191,50 @@ model: width: 300 height: 300 input_tensor: nhwc - input_pixel_format: bgr + input_pixel_format: rgb model_type: ssd - path: /config/model_cache/h8l_cache/ssd_mobilenet_v1.hef + # Specify the local model path (if available) or URL for SSD MobileNet v1. + # Example with a local path: + # path: /config/model_cache/h8l_cache/ssd_mobilenet_v1.hef + # + # Or override using a custom URL: + # path: https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8l/ssd_mobilenet_v1.hef ``` +#### Custom Models + +The Hailo detector supports all YOLO models compiled for Hailo hardware that include post-processing. You can specify a custom URL or a local path to download or use your model directly. If both are provided, the detector checks the local path first. + +```yaml +detectors: + hailo8l: + type: hailo8l + device: PCIe + +model: + width: 640 + height: 640 + input_tensor: nhwc + input_pixel_format: rgb + input_dtype: int + model_type: yolo-generic + # Optional: Specify a local model path. + # path: /config/model_cache/hailo/custom_model.hef + # + # Alternatively, or as a fallback, provide a custom URL: + # 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 + +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:** +> 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 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"`. diff --git a/docs/docs/frigate/hardware.md b/docs/docs/frigate/hardware.md index dcfc91072..f7fdba0fd 100644 --- a/docs/docs/frigate/hardware.md +++ b/docs/docs/frigate/hardware.md @@ -92,11 +92,22 @@ Inference speeds will vary greatly depending on the GPU and the model used. With the [rocm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs. -### Hailo-8l PCIe +### Hailo-8 -Frigate supports the Hailo-8l M.2 card on any hardware but currently it is only tested on the Raspberry Pi5 PCIe hat from the AI kit. +| Name | Hailo‑8 Inference Time | Hailo‑8L Inference Time | +| --------------- | ---------------------- | ----------------------- | +| ssd mobilenet v1| ~ 6 ms | ~ 10 ms | +| yolov6n | ~ 7 ms | ~ 11 ms | + + +Frigate supports both the Hailo-8 and Hailo-8L AI Acceleration Modules on compatible hardware platforms—including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate automatically identifies your hardware type and selects the appropriate default model when a custom model isn’t provided. + +**Default Model Configuration:** +- **Hailo-8L:** Default model is **YOLOv6n**. +- **Hailo-8:** Default model is **YOLOv6n**. + +In real-world deployments, even with multiple cameras running concurrently, Frigate has demonstrated consistent performance. Testing on x86 platforms—with dual PCIe lanes—yields further improvements in FPS, throughput, and latency compared to the Raspberry Pi setup. -The inference time for the Hailo-8L chip at time of writing is around 17-21 ms for the SSD MobileNet Version 1 model. ## Community Supported Detectors diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 9b3920ebc..b270df5ff 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -100,9 +100,9 @@ By default, the Raspberry Pi limits the amount of memory available to the GPU. I Additionally, the USB Coral draws a considerable amount of power. If using any other USB devices such as an SSD, you will experience instability due to the Pi not providing enough power to USB devices. You will need to purchase an external USB hub with it's own power supply. Some have reported success with this (affiliate link). -### Hailo-8L +### Hailo-8 -The Hailo-8L is an M.2 card typically connected to a carrier board for PCIe, which then connects to the Raspberry Pi 5 as part of the AI Kit. However, it can also be used on other boards equipped with an M.2 M key edge connector. +The Hailo-8 and Hailo-8L AI accelerators are available in both M.2 and HAT form factors for the Raspberry Pi. The M.2 version typically connects to a carrier board for PCIe, which then interfaces with the Raspberry Pi 5 as part of the AI Kit. The HAT version can be mounted directly onto compatible Raspberry Pi models. Both form factors have been successfully tested on x86 platforms as well, making them versatile options for various computing environments. #### Installation diff --git a/frigate/detectors/detector_config.py b/frigate/detectors/detector_config.py index fceab5a19..be12e7fcc 100644 --- a/frigate/detectors/detector_config.py +++ b/frigate/detectors/detector_config.py @@ -38,6 +38,7 @@ class ModelTypeEnum(str, Enum): yolov9 = "yolov9" yolonas = "yolonas" dfine = "dfine" + yologeneric = "yolo-generic" class ModelConfig(BaseModel): diff --git a/frigate/detectors/plugins/hailo8l.py b/frigate/detectors/plugins/hailo8l.py old mode 100644 new mode 100755 index 69e86bc5b..ad86ca03d --- a/frigate/detectors/plugins/hailo8l.py +++ b/frigate/detectors/plugins/hailo8l.py @@ -1,286 +1,450 @@ import logging import os +import queue +import subprocess +import threading import urllib.request +from functools import partial +from typing import Dict, List, Optional, Tuple +import cv2 import numpy as np try: from hailo_platform import ( HEF, - ConfigureParams, FormatType, - HailoRTException, - HailoStreamInterface, - InferVStreams, - InputVStreamParams, - OutputVStreamParams, + HailoSchedulingAlgorithm, VDevice, ) except ModuleNotFoundError: pass -from pydantic import BaseModel, Field +from pydantic import Field from typing_extensions import Literal from frigate.const import MODEL_CACHE_DIR from frigate.detectors.detection_api import DetectionApi -from frigate.detectors.detector_config import BaseDetectorConfig +from frigate.detectors.detector_config import ( + BaseDetectorConfig, +) -# Set up logging logger = logging.getLogger(__name__) -# Define the detector key for Hailo + +# ----------------- ResponseStore Class ----------------- # +class ResponseStore: + """ + A thread-safe hash-based response store that maps request IDs + to their results. Threads can wait on the condition variable until + their request's result appears. + """ + + def __init__(self): + self.responses = {} # Maps request_id -> (original_input, infer_results) + self.lock = threading.Lock() + self.cond = threading.Condition(self.lock) + + def put(self, request_id, response): + with self.cond: + self.responses[request_id] = response + self.cond.notify_all() + + def get(self, request_id, timeout=None): + with self.cond: + if not self.cond.wait_for( + lambda: request_id in self.responses, timeout=timeout + ): + raise TimeoutError(f"Timeout waiting for response {request_id}") + return self.responses.pop(request_id) + + +# ----------------- Utility Functions ----------------- # + + +def preprocess_tensor(image: np.ndarray, model_w: int, model_h: int) -> np.ndarray: + """ + Resize an image with unchanged aspect ratio using padding. + Assumes input image shape is (H, W, 3). + """ + if image.ndim == 4 and image.shape[0] == 1: + image = image[0] + + h, w = image.shape[:2] + + if (w, h) == (320, 320) and (model_w, model_h) == (640, 640): + return cv2.resize(image, (model_w, model_h), interpolation=cv2.INTER_LINEAR) + + scale = min(model_w / w, model_h / h) + new_w, new_h = int(w * scale), int(h * scale) + resized_image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_CUBIC) + padded_image = np.full((model_h, model_w, 3), 114, dtype=image.dtype) + x_offset = (model_w - new_w) // 2 + y_offset = (model_h - new_h) // 2 + padded_image[y_offset : y_offset + new_h, x_offset : x_offset + new_w] = ( + resized_image + ) + return padded_image + + +# ----------------- Global Constants ----------------- # DETECTOR_KEY = "hailo8l" +ARCH = None +H8_DEFAULT_MODEL = "yolov6n.hef" +H8L_DEFAULT_MODEL = "yolov6n.hef" +H8_DEFAULT_URL = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8/yolov6n.hef" +H8L_DEFAULT_URL = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.14.0/hailo8l/yolov6n.hef" -# Configuration class for model settings -class ModelConfig(BaseModel): - path: str = Field(default=None, title="Model Path") # Path to the HEF file +def detect_hailo_arch(): + try: + result = subprocess.run( + ["hailortcli", "fw-control", "identify"], capture_output=True, text=True + ) + if result.returncode != 0: + logger.error(f"Inference error: {result.stderr}") + return None + for line in result.stdout.split("\n"): + if "Device Architecture" in line: + if "HAILO8L" in line: + return "hailo8l" + elif "HAILO8" in line: + return "hailo8" + logger.error("Inference error: Could not determine Hailo architecture.") + return None + except Exception as e: + logger.error(f"Inference error: {e}") + return None -# Configuration class for Hailo detector -class HailoDetectorConfig(BaseDetectorConfig): - type: Literal[DETECTOR_KEY] # Type of the detector - device: str = Field(default="PCIe", title="Device Type") # Device type (e.g., PCIe) +# ----------------- HailoAsyncInference Class ----------------- # +class HailoAsyncInference: + def __init__( + self, + hef_path: str, + input_queue: queue.Queue, + output_store: ResponseStore, + batch_size: int = 1, + input_type: Optional[str] = None, + output_type: Optional[Dict[str, str]] = None, + send_original_frame: bool = False, + ) -> None: + self.input_queue = input_queue + self.output_store = output_store + + params = VDevice.create_params() + params.scheduling_algorithm = HailoSchedulingAlgorithm.ROUND_ROBIN + + self.hef = HEF(hef_path) + self.target = VDevice(params) + self.infer_model = self.target.create_infer_model(hef_path) + self.infer_model.set_batch_size(batch_size) + if input_type is not None: + self._set_input_type(input_type) + if output_type is not None: + self._set_output_type(output_type) + self.output_type = output_type + self.send_original_frame = send_original_frame + + def _set_input_type(self, input_type: Optional[str] = None) -> None: + self.infer_model.input().set_format_type(getattr(FormatType, input_type)) + + def _set_output_type( + self, output_type_dict: Optional[Dict[str, str]] = None + ) -> None: + for output_name, output_type in output_type_dict.items(): + self.infer_model.output(output_name).set_format_type( + getattr(FormatType, output_type) + ) + + def callback( + self, + completion_info, + bindings_list: List, + input_batch: List, + request_ids: List[int], + ): + if completion_info.exception: + logger.error(f"Inference error: {completion_info.exception}") + else: + for i, bindings in enumerate(bindings_list): + if len(bindings._output_names) == 1: + result = bindings.output().get_buffer() + else: + result = { + name: np.expand_dims(bindings.output(name).get_buffer(), axis=0) + for name in bindings._output_names + } + self.output_store.put(request_ids[i], (input_batch[i], result)) + + def _create_bindings(self, configured_infer_model) -> object: + if self.output_type is None: + output_buffers = { + output_info.name: np.empty( + self.infer_model.output(output_info.name).shape, + dtype=getattr( + np, str(output_info.format.type).split(".")[1].lower() + ), + ) + for output_info in self.hef.get_output_vstream_infos() + } + else: + output_buffers = { + name: np.empty( + self.infer_model.output(name).shape, + dtype=getattr(np, self.output_type[name].lower()), + ) + for name in self.output_type + } + return configured_infer_model.create_bindings(output_buffers=output_buffers) + + def get_input_shape(self) -> Tuple[int, ...]: + return self.hef.get_input_vstream_infos()[0].shape + + def run(self) -> None: + with self.infer_model.configure() as configured_infer_model: + while True: + batch_data = self.input_queue.get() + if batch_data is None: + break + request_id, frame_data = batch_data + preprocessed_batch = [frame_data] + request_ids = [request_id] + input_batch = preprocessed_batch # non-send_original_frame mode + + bindings_list = [] + for frame in preprocessed_batch: + bindings = self._create_bindings(configured_infer_model) + bindings.input().set_buffer(np.array(frame)) + bindings_list.append(bindings) + configured_infer_model.wait_for_async_ready(timeout_ms=10000) + job = configured_infer_model.run_async( + bindings_list, + partial( + self.callback, + input_batch=input_batch, + request_ids=request_ids, + bindings_list=bindings_list, + ), + ) + job.wait(100) -# Hailo detector class implementation +# ----------------- HailoDetector Class ----------------- # class HailoDetector(DetectionApi): - type_key = DETECTOR_KEY # Set the type key to the Hailo detector key + type_key = DETECTOR_KEY - def __init__(self, detector_config: HailoDetectorConfig): - # Initialize device type and model path from the configuration - self.h8l_device_type = detector_config.device - self.h8l_model_path = detector_config.model.path - self.h8l_model_height = detector_config.model.height - self.h8l_model_width = detector_config.model.width - self.h8l_model_type = detector_config.model.model_type - self.h8l_tensor_format = detector_config.model.input_tensor - self.h8l_pixel_format = detector_config.model.input_pixel_format - self.model_url = "https://hailo-model-zoo.s3.eu-west-2.amazonaws.com/ModelZoo/Compiled/v2.11.0/hailo8l/ssd_mobilenet_v1.hef" - self.cache_dir = os.path.join(MODEL_CACHE_DIR, "h8l_cache") - self.expected_model_filename = "ssd_mobilenet_v1.hef" - output_type = "FLOAT32" + def __init__(self, detector_config: "HailoDetectorConfig"): + global ARCH + ARCH = detect_hailo_arch() + self.cache_dir = MODEL_CACHE_DIR + self.device_type = detector_config.device + self.model_height = ( + detector_config.model.height + if hasattr(detector_config.model, "height") + else None + ) + self.model_width = ( + detector_config.model.width + if hasattr(detector_config.model, "width") + else None + ) + self.model_type = ( + detector_config.model.model_type + if hasattr(detector_config.model, "model_type") + else None + ) + self.tensor_format = ( + detector_config.model.input_tensor + if hasattr(detector_config.model, "input_tensor") + else None + ) + self.pixel_format = ( + detector_config.model.input_pixel_format + if hasattr(detector_config.model, "input_pixel_format") + else None + ) + self.input_dtype = ( + detector_config.model.input_dtype + if hasattr(detector_config.model, "input_dtype") + else None + ) + self.output_type = "FLOAT32" + self.set_path_and_url(detector_config.model.path) + self.working_model_path = self.check_and_prepare() + + self.batch_size = 1 + self.input_queue = queue.Queue() + self.response_store = ResponseStore() + self.request_counter = 0 + self.request_counter_lock = threading.Lock() - logger.info(f"Initializing Hailo device as {self.h8l_device_type}") - self.check_and_prepare_model() try: - # Validate device type - if self.h8l_device_type not in ["PCIe", "M.2"]: - raise ValueError(f"Unsupported device type: {self.h8l_device_type}") - - # Initialize the Hailo device - self.target = VDevice() - # Load the HEF (Hailo's binary format for neural networks) - self.hef = HEF(self.h8l_model_path) - # Create configuration parameters from the HEF - self.configure_params = ConfigureParams.create_from_hef( - hef=self.hef, interface=HailoStreamInterface.PCIe + logger.debug(f"[INIT] Loading HEF model from {self.working_model_path}") + self.inference_engine = HailoAsyncInference( + self.working_model_path, + self.input_queue, + self.response_store, + self.batch_size, ) - # Configure the device with the HEF - self.network_groups = self.target.configure(self.hef, self.configure_params) - self.network_group = self.network_groups[0] - self.network_group_params = self.network_group.create_params() - - # Create input and output virtual stream parameters - self.input_vstream_params = InputVStreamParams.make( - self.network_group, - format_type=self.hef.get_input_vstream_infos()[0].format.type, + self.input_shape = self.inference_engine.get_input_shape() + logger.debug(f"[INIT] Model input shape: {self.input_shape}") + self.inference_thread = threading.Thread( + target=self.inference_engine.run, daemon=True ) - self.output_vstream_params = OutputVStreamParams.make( - self.network_group, format_type=getattr(FormatType, output_type) - ) - - # Get input and output stream information from the HEF - self.input_vstream_info = self.hef.get_input_vstream_infos() - self.output_vstream_info = self.hef.get_output_vstream_infos() - - logger.info("Hailo device initialized successfully") - logger.debug(f"[__init__] Model Path: {self.h8l_model_path}") - logger.debug(f"[__init__] Input Tensor Format: {self.h8l_tensor_format}") - logger.debug(f"[__init__] Input Pixel Format: {self.h8l_pixel_format}") - logger.debug(f"[__init__] Input VStream Info: {self.input_vstream_info[0]}") - logger.debug( - f"[__init__] Output VStream Info: {self.output_vstream_info[0]}" - ) - except HailoRTException as e: - logger.error(f"HailoRTException during initialization: {e}") - raise + self.inference_thread.start() except Exception as e: - logger.error(f"Failed to initialize Hailo device: {e}") + logger.error(f"[INIT] Failed to initialize HailoAsyncInference: {e}") raise - def check_and_prepare_model(self): - # Ensure cache directory exists + def set_path_and_url(self, path: str = None): + if not path: + self.model_path = None + self.url = None + return + if self.is_url(path): + self.url = path + self.model_path = None + else: + self.model_path = path + self.url = None + + def is_url(self, url: str) -> bool: + return ( + url.startswith("http://") + or url.startswith("https://") + or url.startswith("www.") + ) + + @staticmethod + def extract_model_name(path: str = None, url: str = None) -> str: + if path and path.endswith(".hef"): + return os.path.basename(path) + elif url and url.endswith(".hef"): + return os.path.basename(url) + else: + if ARCH == "hailo8": + return H8_DEFAULT_MODEL + else: + return H8L_DEFAULT_MODEL + + @staticmethod + def download_model(url: str, destination: str): + if not url.endswith(".hef"): + raise ValueError("Invalid model URL. Only .hef files are supported.") + try: + urllib.request.urlretrieve(url, destination) + logger.debug(f"Downloaded model to {destination}") + except Exception as e: + raise RuntimeError(f"Failed to download model from {url}: {str(e)}") + + def check_and_prepare(self) -> str: if not os.path.exists(self.cache_dir): os.makedirs(self.cache_dir) + model_name = self.extract_model_name(self.model_path, self.url) + cached_model_path = os.path.join(self.cache_dir, model_name) + if not self.model_path and not self.url: + if os.path.exists(cached_model_path): + logger.debug(f"Model found in cache: {cached_model_path}") + return cached_model_path + else: + logger.debug(f"Downloading default model: {model_name}") + if ARCH == "hailo8": + self.download_model(H8_DEFAULT_URL, cached_model_path) + else: + self.download_model(H8L_DEFAULT_URL, cached_model_path) + elif self.url: + logger.debug(f"Downloading model from URL: {self.url}") + self.download_model(self.url, cached_model_path) + elif self.model_path: + if os.path.exists(self.model_path): + logger.debug(f"Using existing model at: {self.model_path}") + return self.model_path + else: + raise FileNotFoundError(f"Model file not found at: {self.model_path}") + return cached_model_path - # Check for the expected model file - model_file_path = os.path.join(self.cache_dir, self.expected_model_filename) - if not os.path.isfile(model_file_path): - logger.info( - f"A model file was not found at {model_file_path}, Downloading one from {self.model_url}." - ) - urllib.request.urlretrieve(self.model_url, model_file_path) - logger.info(f"A model file was downloaded to {model_file_path}.") - else: - logger.info( - f"A model file already exists at {model_file_path} not downloading one." - ) + def _get_request_id(self) -> int: + with self.request_counter_lock: + request_id = self.request_counter + self.request_counter += 1 + if self.request_counter > 1000000: + self.request_counter = 0 + return request_id def detect_raw(self, tensor_input): - logger.debug("[detect_raw] Entering function") - logger.debug( - f"[detect_raw] The `tensor_input` = {tensor_input} tensor_input shape = {tensor_input.shape}" - ) + request_id = self._get_request_id() - if tensor_input is None: - raise ValueError( - "[detect_raw] The 'tensor_input' argument must be provided" - ) - - # Ensure tensor_input is a numpy array - if isinstance(tensor_input, list): - tensor_input = np.array(tensor_input) - logger.debug( - f"[detect_raw] Converted tensor_input to numpy array: shape {tensor_input.shape}" - ) - - input_data = tensor_input - logger.debug( - f"[detect_raw] Input data for inference shape: {tensor_input.shape}, dtype: {tensor_input.dtype}" - ) + tensor_input = self.preprocess(tensor_input) + if isinstance(tensor_input, np.ndarray) and len(tensor_input.shape) == 3: + tensor_input = np.expand_dims(tensor_input, axis=0) + self.input_queue.put((request_id, tensor_input)) try: - with InferVStreams( - self.network_group, - self.input_vstream_params, - self.output_vstream_params, - ) as infer_pipeline: - input_dict = {} - if isinstance(input_data, dict): - input_dict = input_data - logger.debug("[detect_raw] it a dictionary.") - elif isinstance(input_data, (list, tuple)): - for idx, layer_info in enumerate(self.input_vstream_info): - input_dict[layer_info.name] = input_data[idx] - logger.debug("[detect_raw] converted from list/tuple.") - else: - if len(input_data.shape) == 3: - input_data = np.expand_dims(input_data, axis=0) - logger.debug("[detect_raw] converted from an array.") - input_dict[self.input_vstream_info[0].name] = input_data + original_input, infer_results = self.response_store.get( + request_id, timeout=10.0 + ) + except TimeoutError: + logger.error( + f"Timeout waiting for inference results for request {request_id}" + ) + return np.zeros((20, 6), dtype=np.float32) - logger.debug( - f"[detect_raw] Input dictionary for inference keys: {input_dict.keys()}" - ) + if isinstance(infer_results, list) and len(infer_results) == 1: + infer_results = infer_results[0] - with self.network_group.activate(self.network_group_params): - raw_output = infer_pipeline.infer(input_dict) - logger.debug(f"[detect_raw] Raw inference output: {raw_output}") - - if self.output_vstream_info[0].name not in raw_output: - logger.error( - f"[detect_raw] Missing output stream {self.output_vstream_info[0].name} in inference results" - ) - return np.zeros((20, 6), np.float32) - - raw_output = raw_output[self.output_vstream_info[0].name][0] - logger.debug( - f"[detect_raw] Raw output for stream {self.output_vstream_info[0].name}: {raw_output}" - ) - - # Process the raw output - detections = self.process_detections(raw_output) - if len(detections) == 0: - logger.debug( - "[detect_raw] No detections found after processing. Setting default values." - ) - return np.zeros((20, 6), np.float32) - else: - formatted_detections = detections - if ( - formatted_detections.shape[1] != 6 - ): # Ensure the formatted detections have 6 columns - logger.error( - f"[detect_raw] Unexpected shape for formatted detections: {formatted_detections.shape}. Expected (20, 6)." - ) - return np.zeros((20, 6), np.float32) - return formatted_detections - except HailoRTException as e: - logger.error(f"[detect_raw] HailoRTException during inference: {e}") - return np.zeros((20, 6), np.float32) - except Exception as e: - logger.error(f"[detect_raw] Exception during inference: {e}") - return np.zeros((20, 6), np.float32) - finally: - logger.debug("[detect_raw] Exiting function") - - def process_detections(self, raw_detections, threshold=0.5): - boxes, scores, classes = [], [], [] - num_detections = 0 - - logger.debug(f"[process_detections] Raw detections: {raw_detections}") - - for i, detection_set in enumerate(raw_detections): + threshold = 0.4 + all_detections = [] + for class_id, detection_set in enumerate(infer_results): if not isinstance(detection_set, np.ndarray) or detection_set.size == 0: - logger.debug( - f"[process_detections] Detection set {i} is empty or not an array, skipping." - ) continue - - logger.debug( - f"[process_detections] Detection set {i} shape: {detection_set.shape}" - ) - - for detection in detection_set: - if detection.shape[0] == 0: - logger.debug( - f"[process_detections] Detection in set {i} is empty, skipping." - ) + for det in detection_set: + if det.shape[0] < 5: continue - - ymin, xmin, ymax, xmax = detection[:4] - score = np.clip(detection[4], 0, 1) # Use np.clip for clarity - + score = float(det[4]) if score < threshold: - logger.debug( - f"[process_detections] Detection in set {i} has a score {score} below threshold {threshold}. Skipping." - ) continue + all_detections.append([class_id, score, det[0], det[1], det[2], det[3]]) - logger.debug( - f"[process_detections] Adding detection with coordinates: ({xmin}, {ymin}), ({xmax}, {ymax}) and score: {score}" - ) - boxes.append([ymin, xmin, ymax, xmax]) - scores.append(score) - classes.append(i) - num_detections += 1 + if len(all_detections) == 0: + detections_array = np.zeros((20, 6), dtype=np.float32) + else: + detections_array = np.array(all_detections, dtype=np.float32) + if detections_array.shape[0] > 20: + detections_array = detections_array[:20, :] + elif detections_array.shape[0] < 20: + pad = np.zeros((20 - detections_array.shape[0], 6), dtype=np.float32) + detections_array = np.vstack((detections_array, pad)) - logger.debug( - f"[process_detections] Boxes: {boxes}, Scores: {scores}, Classes: {classes}, Num detections: {num_detections}" - ) + return detections_array - if num_detections == 0: - logger.debug("[process_detections] No valid detections found.") - return np.zeros((20, 6), np.float32) - - combined = np.hstack( - ( - np.array(classes)[:, np.newaxis], - np.array(scores)[:, np.newaxis], - np.array(boxes), + def preprocess(self, image): + if isinstance(image, np.ndarray): + processed = preprocess_tensor( + image, self.input_shape[1], self.input_shape[0] ) - ) + return np.expand_dims(processed, axis=0) + else: + raise ValueError("Unsupported image format for preprocessing") - if combined.shape[0] < 20: - padding = np.zeros( - (20 - combined.shape[0], combined.shape[1]), dtype=combined.dtype - ) - combined = np.vstack((combined, padding)) + def close(self): + """Properly shuts down the inference engine and releases the VDevice.""" + logger.debug("[CLOSE] Closing HailoDetector") + try: + if hasattr(self, "inference_engine"): + if hasattr(self.inference_engine, "target"): + self.inference_engine.target.release() + logger.debug("Hailo VDevice released successfully") + except Exception as e: + logger.error(f"Failed to close Hailo device: {e}") + raise - logger.debug( - f"[process_detections] Combined detections (padded to 20 if necessary): {np.array_str(combined, precision=4, suppress_small=True)}" - ) + def __del__(self): + """Destructor to ensure cleanup when the object is deleted.""" + self.close() - return combined[:20, :6] + +# ----------------- HailoDetectorConfig Class ----------------- # +class HailoDetectorConfig(BaseDetectorConfig): + type: Literal[DETECTOR_KEY] + device: str = Field(default="PCIe", title="Device Type") From c6bed1e10849bc8fac48a8accc4d5ef6effa52b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Mar 2025 17:46:47 -0600 Subject: [PATCH 52/82] Update markupsafe requirement from ==2.1.* to ==3.0.* in /docker/main (#14213) Updates the requirements on [markupsafe](https://github.com/pallets/markupsafe) to permit the latest version. - [Release notes](https://github.com/pallets/markupsafe/releases) - [Changelog](https://github.com/pallets/markupsafe/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/markupsafe/compare/2.1.0...3.0.0) --- updated-dependencies: - dependency-name: markupsafe dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nicolas Mowen --- docker/main/requirements-wheels.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 795007e86..9368cabcd 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -10,7 +10,7 @@ slowapi == 0.1.* imutils == 0.5.* joserfc == 1.0.* pathvalidate == 3.2.* -markupsafe == 2.1.* +markupsafe == 3.0.* python-multipart == 0.0.12 # General mypy == 1.6.1 From b3d5cd9e4bc73bfc267e074e86b18d2569dc8e5c Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 11 Mar 2025 21:31:05 -0600 Subject: [PATCH 53/82] Refactor manual event api to use ZMQ (#17105) * Don't wait for topic * Refactor object processing and camera state * Move manual event handling to camera state / tracked object * Cleanup * Refactor audio to use internal zmq * Cleanup * Clenaup * Cleanup * Quick label fix * Fix tests * Cleanup --- frigate/api/event.py | 40 +- frigate/api/fastapi_app.py | 3 - frigate/api/media.py | 2 +- frigate/app.py | 9 +- frigate/camera/state.py | 464 ++++++++++++++++++++ frigate/comms/event_metadata_updater.py | 2 + frigate/events/audio.py | 75 ++-- frigate/events/external.py | 187 -------- frigate/output/preview.py | 2 +- frigate/review/maintainer.py | 3 +- frigate/test/http_api/base_http_test.py | 1 - frigate/test/test_http.py | 9 - frigate/{ => track}/object_processing.py | 519 +++++------------------ process_clip.py | 2 +- web/src/components/player/LivePlayer.tsx | 4 +- 15 files changed, 648 insertions(+), 674 deletions(-) create mode 100644 frigate/camera/state.py delete mode 100644 frigate/events/external.py rename frigate/{ => track}/object_processing.py (51%) diff --git a/frigate/api/event.py b/frigate/api/event.py index b47fe23c5..91651313d 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -3,6 +3,8 @@ import datetime import logging import os +import random +import string from functools import reduce from pathlib import Path from urllib.parse import unquote @@ -43,9 +45,8 @@ from frigate.api.defs.tags import Tags from frigate.comms.event_metadata_updater import EventMetadataTypeEnum from frigate.const import CLIPS_DIR from frigate.embeddings import EmbeddingsContext -from frigate.events.external import ExternalEventProcessor from frigate.models import Event, ReviewSegment, Timeline -from frigate.object_processing import TrackedObject, TrackedObjectProcessor +from frigate.track.object_processing import TrackedObject from frigate.util.builtin import get_tz_modifiers logger = logging.getLogger(__name__) @@ -1202,28 +1203,25 @@ def create_event( status_code=404, ) - try: - frame_processor: TrackedObjectProcessor = request.app.detected_frames_processor - external_processor: ExternalEventProcessor = request.app.external_processor + now = datetime.datetime.now().timestamp() + rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) + event_id = f"{now}-{rand_id}" - frame = frame_processor.get_current_frame(camera_name) - event_id = external_processor.create_manual_event( + request.app.event_metadata_updater.publish( + EventMetadataTypeEnum.manual_event_create, + ( + now, camera_name, label, - body.source_type, - body.sub_label, - body.score, - body.duration, + event_id, body.include_recording, + body.score, + body.sub_label, + body.duration, + body.source_type, body.draw, - frame, - ) - except Exception as e: - logger.error(e) - return JSONResponse( - content=({"success": False, "message": "An unknown error occurred"}), - status_code=500, - ) + ), + ) return JSONResponse( content=( @@ -1245,7 +1243,9 @@ def create_event( def end_event(request: Request, event_id: str, body: EventsEndBody): try: end_time = body.end_time or datetime.datetime.now().timestamp() - request.app.external_processor.finish_manual_event(event_id, end_time) + request.app.event_metadata_updater.publish( + EventMetadataTypeEnum.manual_event_end, (event_id, end_time) + ) except Exception: return JSONResponse( content=( diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 40df19343..0657752dc 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -27,7 +27,6 @@ from frigate.comms.event_metadata_updater import ( ) from frigate.config import FrigateConfig from frigate.embeddings import EmbeddingsContext -from frigate.events.external import ExternalEventProcessor from frigate.ptz.onvif import OnvifController from frigate.stats.emitter import StatsEmitter from frigate.storage import StorageMaintainer @@ -56,7 +55,6 @@ def create_fastapi_app( detected_frames_processor, storage_maintainer: StorageMaintainer, onvif: OnvifController, - external_processor: ExternalEventProcessor, stats_emitter: StatsEmitter, event_metadata_updater: EventMetadataPublisher, ): @@ -129,7 +127,6 @@ def create_fastapi_app( app.onvif = onvif app.stats_emitter = stats_emitter app.event_metadata_updater = event_metadata_updater - app.external_processor = external_processor app.jwt_token = get_jwt_secret() if frigate_config.auth.enabled else None return app diff --git a/frigate/api/media.py b/frigate/api/media.py index e3f74ea98..b74ec93d1 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -37,7 +37,7 @@ from frigate.const import ( RECORD_DIR, ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment -from frigate.object_processing import TrackedObjectProcessor +from frigate.track.object_processing import TrackedObjectProcessor from frigate.util.builtin import get_tz_modifiers from frigate.util.image import get_image_from_recording from frigate.util.path import get_event_thumbnail_bytes diff --git a/frigate/app.py b/frigate/app.py index af675eaaf..f433fd50f 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -43,7 +43,6 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.embeddings import EmbeddingsContext, manage_embeddings from frigate.events.audio import AudioProcessor from frigate.events.cleanup import EventCleanup -from frigate.events.external import ExternalEventProcessor from frigate.events.maintainer import EventProcessor from frigate.models import ( Event, @@ -57,7 +56,6 @@ from frigate.models import ( User, ) from frigate.object_detection import ObjectDetectProcess -from frigate.object_processing import TrackedObjectProcessor from frigate.output.output import output_frames from frigate.ptz.autotrack import PtzAutoTrackerThread from frigate.ptz.onvif import OnvifController @@ -69,6 +67,7 @@ from frigate.stats.emitter import StatsEmitter from frigate.stats.util import stats_init from frigate.storage import StorageMaintainer from frigate.timeline import TimelineProcessor +from frigate.track.object_processing import TrackedObjectProcessor from frigate.util.builtin import empty_and_close_queue from frigate.util.image import SharedMemoryFrameManager, UntrackedSharedMemory from frigate.util.object import get_camera_regions_grid @@ -318,9 +317,6 @@ class FrigateApp: # Create a client for other processes to use self.embeddings = EmbeddingsContext(self.db) - def init_external_event_processor(self) -> None: - self.external_event_processor = ExternalEventProcessor(self.config) - def init_inter_process_communicator(self) -> None: self.inter_process_communicator = InterProcessCommunicator() self.inter_config_updater = ConfigPublisher() @@ -657,7 +653,6 @@ class FrigateApp: self.start_camera_capture_processes() self.start_audio_processor() self.start_storage_maintainer() - self.init_external_event_processor() self.start_stats_emitter() self.start_timeline_processor() self.start_event_processor() @@ -676,7 +671,6 @@ class FrigateApp: self.detected_frames_processor, self.storage_maintainer, self.onvif_controller, - self.external_event_processor, self.stats_emitter, self.event_metadata_updater, ), @@ -748,7 +742,6 @@ class FrigateApp: self.review_segment_process.terminate() self.review_segment_process.join() - self.external_event_processor.stop() self.dispatcher.stop() self.ptz_autotracker_thread.join() diff --git a/frigate/camera/state.py b/frigate/camera/state.py new file mode 100644 index 000000000..dfd6744e2 --- /dev/null +++ b/frigate/camera/state.py @@ -0,0 +1,464 @@ +"""Maintains state of camera.""" + +import datetime +import logging +import os +import threading +from collections import defaultdict +from typing import Callable + +import cv2 +import numpy as np + +from frigate.config import ( + FrigateConfig, + ZoomingModeEnum, +) +from frigate.const import CLIPS_DIR, THUMB_DIR +from frigate.ptz.autotrack import PtzAutoTrackerThread +from frigate.track.tracked_object import TrackedObject +from frigate.util.image import ( + SharedMemoryFrameManager, + draw_box_with_label, + draw_timestamp, + is_better_thumbnail, + is_label_printable, +) + +logger = logging.getLogger(__name__) + + +class CameraState: + def __init__( + self, + name, + config: FrigateConfig, + frame_manager: SharedMemoryFrameManager, + ptz_autotracker_thread: PtzAutoTrackerThread, + ): + self.name = name + self.config = config + self.camera_config = config.cameras[name] + self.frame_manager = frame_manager + self.best_objects: dict[str, TrackedObject] = {} + self.tracked_objects: dict[str, TrackedObject] = {} + self.frame_cache = {} + self.zone_objects = defaultdict(list) + self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8) + self.current_frame_lock = threading.Lock() + self.current_frame_time = 0.0 + self.motion_boxes = [] + self.regions = [] + self.previous_frame_id = None + self.callbacks = defaultdict(list) + self.ptz_autotracker_thread = ptz_autotracker_thread + self.prev_enabled = self.camera_config.enabled + + def get_current_frame(self, draw_options={}): + with self.current_frame_lock: + frame_copy = np.copy(self._current_frame) + frame_time = self.current_frame_time + tracked_objects = {k: v.to_dict() for k, v in self.tracked_objects.items()} + motion_boxes = self.motion_boxes.copy() + regions = self.regions.copy() + + frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) + # draw on the frame + if draw_options.get("mask"): + mask_overlay = np.where(self.camera_config.motion.mask == [0]) + frame_copy[mask_overlay] = [0, 0, 0] + + if draw_options.get("bounding_boxes"): + # draw the bounding boxes on the frame + for obj in tracked_objects.values(): + if obj["frame_time"] == frame_time: + if obj["stationary"]: + color = (220, 220, 220) + thickness = 1 + else: + thickness = 2 + color = self.config.model.colormap[obj["label"]] + else: + thickness = 1 + color = (255, 0, 0) + + # draw thicker box around ptz autotracked object + if ( + self.camera_config.onvif.autotracking.enabled + and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init[ + self.name + ] + and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[ + self.name + ] + is not None + and obj["id"] + == self.ptz_autotracker_thread.ptz_autotracker.tracked_object[ + self.name + ].obj_data["id"] + and obj["frame_time"] == frame_time + ): + thickness = 5 + color = self.config.model.colormap[obj["label"]] + + # debug autotracking zooming - show the zoom factor box + if ( + self.camera_config.onvif.autotracking.zooming + != ZoomingModeEnum.disabled + ): + max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[ + self.name + ]["max_target_box"] + side_length = max_target_box * ( + max( + self.camera_config.detect.width, + self.camera_config.detect.height, + ) + ) + + centroid_x = (obj["box"][0] + obj["box"][2]) // 2 + centroid_y = (obj["box"][1] + obj["box"][3]) // 2 + top_left = ( + int(centroid_x - side_length // 2), + int(centroid_y - side_length // 2), + ) + bottom_right = ( + int(centroid_x + side_length // 2), + int(centroid_y + side_length // 2), + ) + cv2.rectangle( + frame_copy, + top_left, + bottom_right, + (255, 255, 0), + 2, + ) + + # draw the bounding boxes on the frame + box = obj["box"] + text = ( + obj["label"] + if ( + not obj.get("sub_label") + or not is_label_printable(obj["sub_label"][0]) + ) + else obj["sub_label"][0] + ) + draw_box_with_label( + frame_copy, + box[0], + box[1], + box[2], + box[3], + text, + f"{obj['score']:.0%} {int(obj['area'])}" + + ( + f" {float(obj['current_estimated_speed']):.1f}" + if obj["current_estimated_speed"] != 0 + else "" + ), + thickness=thickness, + color=color, + ) + + # draw any attributes + for attribute in obj["current_attributes"]: + box = attribute["box"] + draw_box_with_label( + frame_copy, + box[0], + box[1], + box[2], + box[3], + attribute["label"], + f"{attribute['score']:.0%}", + thickness=thickness, + color=color, + ) + + if draw_options.get("regions"): + for region in regions: + cv2.rectangle( + frame_copy, + (region[0], region[1]), + (region[2], region[3]), + (0, 255, 0), + 2, + ) + + if draw_options.get("zones"): + for name, zone in self.camera_config.zones.items(): + thickness = ( + 8 + if any( + name in obj["current_zones"] for obj in tracked_objects.values() + ) + else 2 + ) + cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness) + + if draw_options.get("motion_boxes"): + for m_box in motion_boxes: + cv2.rectangle( + frame_copy, + (m_box[0], m_box[1]), + (m_box[2], m_box[3]), + (0, 0, 255), + 2, + ) + + if draw_options.get("timestamp"): + color = self.camera_config.timestamp_style.color + draw_timestamp( + frame_copy, + frame_time, + self.camera_config.timestamp_style.format, + font_effect=self.camera_config.timestamp_style.effect, + font_thickness=self.camera_config.timestamp_style.thickness, + font_color=(color.blue, color.green, color.red), + position=self.camera_config.timestamp_style.position, + ) + + return frame_copy + + def finished(self, obj_id): + del self.tracked_objects[obj_id] + + def on(self, event_type: str, callback: Callable[[dict], None]): + self.callbacks[event_type].append(callback) + + def update( + self, + frame_name: str, + frame_time: float, + current_detections: dict[str, dict[str, any]], + motion_boxes: list[tuple[int, int, int, int]], + regions: list[tuple[int, int, int, int]], + ): + current_frame = self.frame_manager.get( + frame_name, self.camera_config.frame_shape_yuv + ) + + tracked_objects = self.tracked_objects.copy() + current_ids = set(current_detections.keys()) + previous_ids = set(tracked_objects.keys()) + removed_ids = previous_ids.difference(current_ids) + new_ids = current_ids.difference(previous_ids) + updated_ids = current_ids.intersection(previous_ids) + + for id in new_ids: + new_obj = tracked_objects[id] = TrackedObject( + self.config.model, + self.camera_config, + self.config.ui, + self.frame_cache, + current_detections[id], + ) + + # call event handlers + for c in self.callbacks["start"]: + c(self.name, new_obj, frame_name) + + for id in updated_ids: + updated_obj = tracked_objects[id] + thumb_update, significant_update, autotracker_update = updated_obj.update( + frame_time, current_detections[id], current_frame is not None + ) + + if autotracker_update or significant_update: + for c in self.callbacks["autotrack"]: + c(self.name, updated_obj, frame_name) + + if thumb_update and current_frame is not None: + # ensure this frame is stored in the cache + if ( + updated_obj.thumbnail_data["frame_time"] == frame_time + and frame_time not in self.frame_cache + ): + self.frame_cache[frame_time] = np.copy(current_frame) + + updated_obj.last_updated = frame_time + + # if it has been more than 5 seconds since the last thumb update + # and the last update is greater than the last publish or + # the object has changed significantly + if ( + frame_time - updated_obj.last_published > 5 + and updated_obj.last_updated > updated_obj.last_published + ) or significant_update: + # call event handlers + for c in self.callbacks["update"]: + c(self.name, updated_obj, frame_name) + updated_obj.last_published = frame_time + + for id in removed_ids: + # publish events to mqtt + removed_obj = tracked_objects[id] + if "end_time" not in removed_obj.obj_data: + removed_obj.obj_data["end_time"] = frame_time + for c in self.callbacks["end"]: + c(self.name, removed_obj, frame_name) + + # TODO: can i switch to looking this up and only changing when an event ends? + # maintain best objects + camera_activity: dict[str, list[any]] = { + "enabled": True, + "motion": len(motion_boxes) > 0, + "objects": [], + } + + for obj in tracked_objects.values(): + object_type = obj.obj_data["label"] + active = obj.is_active() + + if not obj.false_positive: + label = object_type + sub_label = None + + if obj.obj_data.get("sub_label"): + if ( + obj.obj_data.get("sub_label")[0] + in self.config.model.all_attributes + ): + label = obj.obj_data["sub_label"][0] + else: + label = f"{object_type}-verified" + sub_label = obj.obj_data["sub_label"][0] + + camera_activity["objects"].append( + { + "id": obj.obj_data["id"], + "label": label, + "stationary": not active, + "area": obj.obj_data["area"], + "ratio": obj.obj_data["ratio"], + "score": obj.obj_data["score"], + "sub_label": sub_label, + "current_zones": obj.current_zones, + } + ) + + # if we don't have access to the current frame or + # if the object's thumbnail is not from the current frame, skip + if ( + current_frame is None + or obj.thumbnail_data is None + or obj.false_positive + or obj.thumbnail_data["frame_time"] != frame_time + ): + continue + + if object_type in self.best_objects: + current_best = self.best_objects[object_type] + now = datetime.datetime.now().timestamp() + # if the object is a higher score than the current best score + # or the current object is older than desired, use the new object + if ( + is_better_thumbnail( + object_type, + current_best.thumbnail_data, + obj.thumbnail_data, + self.camera_config.frame_shape, + ) + or (now - current_best.thumbnail_data["frame_time"]) + > self.camera_config.best_image_timeout + ): + self.best_objects[object_type] = obj + for c in self.callbacks["snapshot"]: + c(self.name, self.best_objects[object_type], frame_name) + else: + self.best_objects[object_type] = obj + for c in self.callbacks["snapshot"]: + c(self.name, self.best_objects[object_type], frame_name) + + for c in self.callbacks["camera_activity"]: + c(self.name, camera_activity) + + # cleanup thumbnail frame cache + current_thumb_frames = { + obj.thumbnail_data["frame_time"] + for obj in tracked_objects.values() + if not obj.false_positive and obj.thumbnail_data is not None + } + current_best_frames = { + obj.thumbnail_data["frame_time"] for obj in self.best_objects.values() + } + thumb_frames_to_delete = [ + t + for t in self.frame_cache.keys() + if t not in current_thumb_frames and t not in current_best_frames + ] + for t in thumb_frames_to_delete: + del self.frame_cache[t] + + with self.current_frame_lock: + self.tracked_objects = tracked_objects + self.motion_boxes = motion_boxes + self.regions = regions + + if current_frame is not None: + self.current_frame_time = frame_time + self._current_frame = np.copy(current_frame) + + if self.previous_frame_id is not None: + self.frame_manager.close(self.previous_frame_id) + + self.previous_frame_id = frame_name + + def save_manual_event_image( + self, event_id: str, label: str, draw: dict[str, list[dict]] + ) -> None: + img_frame = self.get_current_frame() + + # write clean snapshot if enabled + if self.camera_config.snapshots.clean_copy: + ret, png = cv2.imencode(".png", img_frame) + + if ret: + with open( + os.path.join( + CLIPS_DIR, + f"{self.camera_config.name}-{event_id}-clean.png", + ), + "wb", + ) as p: + p.write(png.tobytes()) + + # write jpg snapshot with optional annotations + if draw.get("boxes") and isinstance(draw.get("boxes"), list): + for box in draw.get("boxes"): + x = int(box["box"][0] * self.camera_config.detect.width) + y = int(box["box"][1] * self.camera_config.detect.height) + width = int(box["box"][2] * self.camera_config.detect.width) + height = int(box["box"][3] * self.camera_config.detect.height) + + draw_box_with_label( + img_frame, + x, + y, + x + width, + y + height, + label, + f"{box.get('score', '-')}% {int(width * height)}", + thickness=2, + color=box.get("color", (255, 0, 0)), + ) + + ret, jpg = cv2.imencode(".jpg", img_frame) + with open( + os.path.join(CLIPS_DIR, f"{self.camera_config.name}-{event_id}.jpg"), + "wb", + ) as j: + j.write(jpg.tobytes()) + + # create thumbnail with max height of 175 and save + width = int(175 * img_frame.shape[1] / img_frame.shape[0]) + thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA) + cv2.imwrite( + os.path.join(THUMB_DIR, self.camera_config.name, f"{event_id}.webp"), thumb + ) + + def shutdown(self) -> None: + for obj in self.tracked_objects.values(): + if not obj.obj_data.get("end_time"): + obj.write_thumbnail_to_disk() diff --git a/frigate/comms/event_metadata_updater.py b/frigate/comms/event_metadata_updater.py index f3301aef4..3342182c3 100644 --- a/frigate/comms/event_metadata_updater.py +++ b/frigate/comms/event_metadata_updater.py @@ -10,6 +10,8 @@ logger = logging.getLogger(__name__) class EventMetadataTypeEnum(str, Enum): all = "" + manual_event_create = "manual_event_create" + manual_event_end = "manual_event_end" regenerate_description = "regenerate_description" sub_label = "sub_label" diff --git a/frigate/events/audio.py b/frigate/events/audio.py index 1a4fdd144..adf45431e 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -2,17 +2,22 @@ import datetime import logging +import random +import string import threading import time from typing import Tuple import numpy as np -import requests import frigate.util as util from frigate.camera import CameraMetrics from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum +from frigate.comms.event_metadata_updater import ( + EventMetadataPublisher, + EventMetadataTypeEnum, +) from frigate.comms.inter_process import InterProcessRequestor from frigate.config import CameraConfig, CameraInput, FfmpegConfig from frigate.const import ( @@ -21,7 +26,6 @@ from frigate.const import ( AUDIO_MAX_BIT_RANGE, AUDIO_MIN_CONFIDENCE, AUDIO_SAMPLE_RATE, - FRIGATE_LOCALHOST, ) from frigate.ffmpeg_presets import parse_preset_input from frigate.log import LogPipe @@ -139,6 +143,7 @@ class AudioEventMaintainer(threading.Thread): f"config/enabled/{camera.name}", True ) self.detection_publisher = DetectionPublisher(DetectionTypeEnum.audio) + self.event_metadata_publisher = EventMetadataPublisher() self.was_enabled = camera.enabled @@ -207,24 +212,33 @@ class AudioEventMaintainer(threading.Thread): datetime.datetime.now().timestamp() ) else: + now = datetime.datetime.now().timestamp() + rand_id = "".join( + random.choices(string.ascii_lowercase + string.digits, k=6) + ) + event_id = f"{now}-{rand_id}" self.requestor.send_data(f"{self.config.name}/audio/{label}", "ON") - resp = requests.post( - f"{FRIGATE_LOCALHOST}/api/events/{self.config.name}/{label}/create", - json={"duration": None, "score": score, "source_type": "audio"}, + self.event_metadata_publisher.publish( + EventMetadataTypeEnum.manual_event_create, + ( + now, + self.config.name, + label, + event_id, + True, + score, + None, + None, + "audio", + {}, + ), ) - - if resp.status_code == 200: - event_id = resp.json()["event_id"] - self.detections[label] = { - "id": event_id, - "label": label, - "last_detection": datetime.datetime.now().timestamp(), - } - else: - self.logger.warning( - f"Failed to create audio event with status code {resp.status_code}" - ) + self.detections[label] = { + "id": event_id, + "label": label, + "last_detection": now, + } def expire_detections(self) -> None: now = datetime.datetime.now().timestamp() @@ -241,17 +255,11 @@ class AudioEventMaintainer(threading.Thread): f"{self.config.name}/audio/{detection['label']}", "OFF" ) - resp = requests.put( - f"{FRIGATE_LOCALHOST}/api/events/{detection['id']}/end", - json={"end_time": detection["last_detection"]}, + self.event_metadata_publisher.publish( + EventMetadataTypeEnum.manual_event_end, + (detection["id"], detection["last_detection"]), ) - - if resp.status_code == 200: - self.detections[detection["label"]] = None - else: - self.logger.warning( - f"Failed to end audio event {detection['id']} with status code {resp.status_code}" - ) + self.detections[detection["label"]] = None def expire_all_detections(self) -> None: """Immediately end all current detections""" @@ -259,16 +267,11 @@ class AudioEventMaintainer(threading.Thread): for label, detection in list(self.detections.items()): if detection: self.requestor.send_data(f"{self.config.name}/audio/{label}", "OFF") - resp = requests.put( - f"{FRIGATE_LOCALHOST}/api/events/{detection['id']}/end", - json={"end_time": now}, + self.event_metadata_publisher.publish( + EventMetadataTypeEnum.manual_event_end, + (detection["id"], now), ) - if resp.status_code == 200: - self.detections[label] = None - else: - self.logger.warning( - f"Failed to end audio event {detection['id']} with status code {resp.status_code}" - ) + self.detections[label] = None def start_or_restart_ffmpeg(self) -> None: self.audio_listener = start_or_restart_ffmpeg( diff --git a/frigate/events/external.py b/frigate/events/external.py deleted file mode 100644 index 5423d08be..000000000 --- a/frigate/events/external.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Handle external events created by the user.""" - -import datetime -import logging -import os -import random -import string -from enum import Enum -from typing import Optional - -import cv2 -from numpy import ndarray - -from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum -from frigate.comms.events_updater import EventUpdatePublisher -from frigate.config import CameraConfig, FrigateConfig -from frigate.const import CLIPS_DIR, THUMB_DIR -from frigate.events.types import EventStateEnum, EventTypeEnum -from frigate.util.image import draw_box_with_label - -logger = logging.getLogger(__name__) - - -class ManualEventState(str, Enum): - complete = "complete" - start = "start" - end = "end" - - -class ExternalEventProcessor: - def __init__(self, config: FrigateConfig) -> None: - self.config = config - self.default_thumbnail = None - self.event_sender = EventUpdatePublisher() - self.detection_updater = DetectionPublisher(DetectionTypeEnum.api) - self.event_camera = {} - - def create_manual_event( - self, - camera: str, - label: str, - source_type: str, - sub_label: Optional[str], - score: int, - duration: Optional[int], - include_recording: bool, - draw: dict[str, any], - snapshot_frame: Optional[ndarray], - ) -> str: - now = datetime.datetime.now().timestamp() - camera_config = self.config.cameras.get(camera) - - # create event id and start frame time - rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) - event_id = f"{now}-{rand_id}" - - self._write_images(camera_config, label, event_id, draw, snapshot_frame) - end = now + duration if duration is not None else None - - self.event_sender.publish( - ( - EventTypeEnum.api, - EventStateEnum.start, - camera, - "", - { - "id": event_id, - "label": label, - "sub_label": sub_label, - "score": score, - "camera": camera, - "start_time": now - camera_config.record.event_pre_capture, - "end_time": end, - "has_clip": camera_config.record.enabled and include_recording, - "has_snapshot": True, - "type": source_type, - }, - ) - ) - - if source_type == "api": - self.event_camera[event_id] = camera - self.detection_updater.publish( - ( - camera, - now, - { - "state": ( - ManualEventState.complete if end else ManualEventState.start - ), - "label": f"{label}: {sub_label}" if sub_label else label, - "event_id": event_id, - "end_time": end, - }, - ) - ) - - return event_id - - def finish_manual_event(self, event_id: str, end_time: float) -> None: - """Finish external event with indeterminate duration.""" - self.event_sender.publish( - ( - EventTypeEnum.api, - EventStateEnum.end, - None, - "", - {"id": event_id, "end_time": end_time}, - ) - ) - - if event_id in self.event_camera: - self.detection_updater.publish( - ( - self.event_camera[event_id], - end_time, - { - "state": ManualEventState.end, - "event_id": event_id, - "end_time": end_time, - }, - ) - ) - self.event_camera.pop(event_id) - - def _write_images( - self, - camera_config: CameraConfig, - label: str, - event_id: str, - draw: dict[str, any], - img_frame: Optional[ndarray], - ) -> None: - if img_frame is None: - return - - # write clean snapshot if enabled - if camera_config.snapshots.clean_copy: - ret, png = cv2.imencode(".png", img_frame) - - if ret: - with open( - os.path.join( - CLIPS_DIR, - f"{camera_config.name}-{event_id}-clean.png", - ), - "wb", - ) as p: - p.write(png.tobytes()) - - # write jpg snapshot with optional annotations - if draw.get("boxes") and isinstance(draw.get("boxes"), list): - for box in draw.get("boxes"): - x = int(box["box"][0] * camera_config.detect.width) - y = int(box["box"][1] * camera_config.detect.height) - width = int(box["box"][2] * camera_config.detect.width) - height = int(box["box"][3] * camera_config.detect.height) - - draw_box_with_label( - img_frame, - x, - y, - x + width, - y + height, - label, - f"{box.get('score', '-')}% {int(width * height)}", - thickness=2, - color=box.get("color", (255, 0, 0)), - ) - - ret, jpg = cv2.imencode(".jpg", img_frame) - with open( - os.path.join(CLIPS_DIR, f"{camera_config.name}-{event_id}.jpg"), - "wb", - ) as j: - j.write(jpg.tobytes()) - - # create thumbnail with max height of 175 and save - width = int(175 * img_frame.shape[1] / img_frame.shape[0]) - thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA) - cv2.imwrite( - os.path.join(THUMB_DIR, camera_config.name, f"{event_id}.webp"), thumb - ) - - def stop(self): - self.event_sender.stop() - self.detection_updater.stop() diff --git a/frigate/output/preview.py b/frigate/output/preview.py index 247886bfd..3ffca2f04 100644 --- a/frigate/output/preview.py +++ b/frigate/output/preview.py @@ -22,7 +22,7 @@ from frigate.ffmpeg_presets import ( parse_preset_hardware_acceleration_encode, ) from frigate.models import Previews -from frigate.object_processing import TrackedObject +from frigate.track.object_processing import TrackedObject from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop logger = logging.getLogger(__name__) diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 1c015d217..3541fef3b 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -23,10 +23,9 @@ from frigate.const import ( CLIPS_DIR, UPSERT_REVIEW_SEGMENT, ) -from frigate.events.external import ManualEventState from frigate.models import ReviewSegment -from frigate.object_processing import TrackedObject from frigate.review.types import SeverityEnum +from frigate.track.object_processing import ManualEventState, TrackedObject from frigate.util.image import SharedMemoryFrameManager, calculate_16_9_crop logger = logging.getLogger(__name__) diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index f5a0aca3c..35cda7b79 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -117,7 +117,6 @@ class BaseTestHttp(unittest.TestCase): None, None, None, - None, stats, None, ) diff --git a/frigate/test/test_http.py b/frigate/test/test_http.py index d23727672..4d949c543 100644 --- a/frigate/test/test_http.py +++ b/frigate/test/test_http.py @@ -122,7 +122,6 @@ class TestHttp(unittest.TestCase): None, None, None, - None, ) id = "123456.random" @@ -144,7 +143,6 @@ class TestHttp(unittest.TestCase): None, None, None, - None, ) id = "123456.random" bad_id = "654321.other" @@ -165,7 +163,6 @@ class TestHttp(unittest.TestCase): None, None, None, - None, ) id = "123456.random" @@ -188,7 +185,6 @@ class TestHttp(unittest.TestCase): None, None, None, - None, ) id = "123456.random" @@ -215,7 +211,6 @@ class TestHttp(unittest.TestCase): None, None, None, - None, ) morning_id = "123456.random" evening_id = "654321.random" @@ -254,7 +249,6 @@ class TestHttp(unittest.TestCase): None, None, None, - None, mock_event_updater, ) id = "123456.random" @@ -300,7 +294,6 @@ class TestHttp(unittest.TestCase): None, None, None, - None, mock_event_updater, ) id = "123456.random" @@ -334,7 +327,6 @@ class TestHttp(unittest.TestCase): None, None, None, - None, ) with TestClient(app) as client: @@ -352,7 +344,6 @@ class TestHttp(unittest.TestCase): None, None, None, - None, ) id = "123456.random" diff --git a/frigate/object_processing.py b/frigate/track/object_processing.py similarity index 51% rename from frigate/object_processing.py rename to frigate/track/object_processing.py index d31ca83e1..ddac2b588 100644 --- a/frigate/object_processing.py +++ b/frigate/track/object_processing.py @@ -4,13 +4,13 @@ import logging import queue import threading from collections import defaultdict +from enum import Enum from multiprocessing.synchronize import Event as MpEvent -from typing import Callable, Optional -import cv2 import numpy as np from peewee import DoesNotExist +from frigate.camera.state import CameraState from frigate.comms.config_updater import ConfigSubscriber from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.dispatcher import Dispatcher @@ -25,406 +25,20 @@ from frigate.config import ( FrigateConfig, RecordConfig, SnapshotsConfig, - ZoomingModeEnum, ) from frigate.const import UPDATE_CAMERA_ACTIVITY from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.models import Event, Timeline -from frigate.ptz.autotrack import PtzAutoTrackerThread from frigate.track.tracked_object import TrackedObject -from frigate.util.image import ( - SharedMemoryFrameManager, - draw_box_with_label, - draw_timestamp, - is_better_thumbnail, - is_label_printable, -) +from frigate.util.image import SharedMemoryFrameManager logger = logging.getLogger(__name__) -# Maintains the state of a camera -class CameraState: - def __init__( - self, - name, - config: FrigateConfig, - frame_manager: SharedMemoryFrameManager, - ptz_autotracker_thread: PtzAutoTrackerThread, - ): - self.name = name - self.config = config - self.camera_config = config.cameras[name] - self.frame_manager = frame_manager - self.best_objects: dict[str, TrackedObject] = {} - self.tracked_objects: dict[str, TrackedObject] = {} - self.frame_cache = {} - self.zone_objects = defaultdict(list) - self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8) - self.current_frame_lock = threading.Lock() - self.current_frame_time = 0.0 - self.motion_boxes = [] - self.regions = [] - self.previous_frame_id = None - self.callbacks = defaultdict(list) - self.ptz_autotracker_thread = ptz_autotracker_thread - self.prev_enabled = self.camera_config.enabled - - def get_current_frame(self, draw_options={}): - with self.current_frame_lock: - frame_copy = np.copy(self._current_frame) - frame_time = self.current_frame_time - tracked_objects = {k: v.to_dict() for k, v in self.tracked_objects.items()} - motion_boxes = self.motion_boxes.copy() - regions = self.regions.copy() - - frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) - # draw on the frame - if draw_options.get("mask"): - mask_overlay = np.where(self.camera_config.motion.mask == [0]) - frame_copy[mask_overlay] = [0, 0, 0] - - if draw_options.get("bounding_boxes"): - # draw the bounding boxes on the frame - for obj in tracked_objects.values(): - if obj["frame_time"] == frame_time: - if obj["stationary"]: - color = (220, 220, 220) - thickness = 1 - else: - thickness = 2 - color = self.config.model.colormap[obj["label"]] - else: - thickness = 1 - color = (255, 0, 0) - - # draw thicker box around ptz autotracked object - if ( - self.camera_config.onvif.autotracking.enabled - and self.ptz_autotracker_thread.ptz_autotracker.autotracker_init[ - self.name - ] - and self.ptz_autotracker_thread.ptz_autotracker.tracked_object[ - self.name - ] - is not None - and obj["id"] - == self.ptz_autotracker_thread.ptz_autotracker.tracked_object[ - self.name - ].obj_data["id"] - and obj["frame_time"] == frame_time - ): - thickness = 5 - color = self.config.model.colormap[obj["label"]] - - # debug autotracking zooming - show the zoom factor box - if ( - self.camera_config.onvif.autotracking.zooming - != ZoomingModeEnum.disabled - ): - max_target_box = self.ptz_autotracker_thread.ptz_autotracker.tracked_object_metrics[ - self.name - ]["max_target_box"] - side_length = max_target_box * ( - max( - self.camera_config.detect.width, - self.camera_config.detect.height, - ) - ) - - centroid_x = (obj["box"][0] + obj["box"][2]) // 2 - centroid_y = (obj["box"][1] + obj["box"][3]) // 2 - top_left = ( - int(centroid_x - side_length // 2), - int(centroid_y - side_length // 2), - ) - bottom_right = ( - int(centroid_x + side_length // 2), - int(centroid_y + side_length // 2), - ) - cv2.rectangle( - frame_copy, - top_left, - bottom_right, - (255, 255, 0), - 2, - ) - - # draw the bounding boxes on the frame - box = obj["box"] - text = ( - obj["label"] - if ( - not obj.get("sub_label") - or not is_label_printable(obj["sub_label"][0]) - ) - else obj["sub_label"][0] - ) - draw_box_with_label( - frame_copy, - box[0], - box[1], - box[2], - box[3], - text, - f"{obj['score']:.0%} {int(obj['area'])}" - + ( - f" {float(obj['current_estimated_speed']):.1f}" - if obj["current_estimated_speed"] != 0 - else "" - ), - thickness=thickness, - color=color, - ) - - # draw any attributes - for attribute in obj["current_attributes"]: - box = attribute["box"] - draw_box_with_label( - frame_copy, - box[0], - box[1], - box[2], - box[3], - attribute["label"], - f"{attribute['score']:.0%}", - thickness=thickness, - color=color, - ) - - if draw_options.get("regions"): - for region in regions: - cv2.rectangle( - frame_copy, - (region[0], region[1]), - (region[2], region[3]), - (0, 255, 0), - 2, - ) - - if draw_options.get("zones"): - for name, zone in self.camera_config.zones.items(): - thickness = ( - 8 - if any( - name in obj["current_zones"] for obj in tracked_objects.values() - ) - else 2 - ) - cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness) - - if draw_options.get("motion_boxes"): - for m_box in motion_boxes: - cv2.rectangle( - frame_copy, - (m_box[0], m_box[1]), - (m_box[2], m_box[3]), - (0, 0, 255), - 2, - ) - - if draw_options.get("timestamp"): - color = self.camera_config.timestamp_style.color - draw_timestamp( - frame_copy, - frame_time, - self.camera_config.timestamp_style.format, - font_effect=self.camera_config.timestamp_style.effect, - font_thickness=self.camera_config.timestamp_style.thickness, - font_color=(color.blue, color.green, color.red), - position=self.camera_config.timestamp_style.position, - ) - - return frame_copy - - def finished(self, obj_id): - del self.tracked_objects[obj_id] - - def on(self, event_type: str, callback: Callable[[dict], None]): - self.callbacks[event_type].append(callback) - - def update( - self, - frame_name: str, - frame_time: float, - current_detections: dict[str, dict[str, any]], - motion_boxes: list[tuple[int, int, int, int]], - regions: list[tuple[int, int, int, int]], - ): - current_frame = self.frame_manager.get( - frame_name, self.camera_config.frame_shape_yuv - ) - - tracked_objects = self.tracked_objects.copy() - current_ids = set(current_detections.keys()) - previous_ids = set(tracked_objects.keys()) - removed_ids = previous_ids.difference(current_ids) - new_ids = current_ids.difference(previous_ids) - updated_ids = current_ids.intersection(previous_ids) - - for id in new_ids: - new_obj = tracked_objects[id] = TrackedObject( - self.config.model, - self.camera_config, - self.config.ui, - self.frame_cache, - current_detections[id], - ) - - # call event handlers - for c in self.callbacks["start"]: - c(self.name, new_obj, frame_name) - - for id in updated_ids: - updated_obj = tracked_objects[id] - thumb_update, significant_update, autotracker_update = updated_obj.update( - frame_time, current_detections[id], current_frame is not None - ) - - if autotracker_update or significant_update: - for c in self.callbacks["autotrack"]: - c(self.name, updated_obj, frame_name) - - if thumb_update and current_frame is not None: - # ensure this frame is stored in the cache - if ( - updated_obj.thumbnail_data["frame_time"] == frame_time - and frame_time not in self.frame_cache - ): - self.frame_cache[frame_time] = np.copy(current_frame) - - updated_obj.last_updated = frame_time - - # if it has been more than 5 seconds since the last thumb update - # and the last update is greater than the last publish or - # the object has changed significantly - if ( - frame_time - updated_obj.last_published > 5 - and updated_obj.last_updated > updated_obj.last_published - ) or significant_update: - # call event handlers - for c in self.callbacks["update"]: - c(self.name, updated_obj, frame_name) - updated_obj.last_published = frame_time - - for id in removed_ids: - # publish events to mqtt - removed_obj = tracked_objects[id] - if "end_time" not in removed_obj.obj_data: - removed_obj.obj_data["end_time"] = frame_time - for c in self.callbacks["end"]: - c(self.name, removed_obj, frame_name) - - # TODO: can i switch to looking this up and only changing when an event ends? - # maintain best objects - camera_activity: dict[str, list[any]] = { - "enabled": True, - "motion": len(motion_boxes) > 0, - "objects": [], - } - - for obj in tracked_objects.values(): - object_type = obj.obj_data["label"] - active = obj.is_active() - - if not obj.false_positive: - label = object_type - sub_label = None - - if obj.obj_data.get("sub_label"): - if ( - obj.obj_data.get("sub_label")[0] - in self.config.model.all_attributes - ): - label = obj.obj_data["sub_label"][0] - else: - label = f"{object_type}-verified" - sub_label = obj.obj_data["sub_label"][0] - - camera_activity["objects"].append( - { - "id": obj.obj_data["id"], - "label": label, - "stationary": not active, - "area": obj.obj_data["area"], - "ratio": obj.obj_data["ratio"], - "score": obj.obj_data["score"], - "sub_label": sub_label, - "current_zones": obj.current_zones, - } - ) - - # if we don't have access to the current frame or - # if the object's thumbnail is not from the current frame, skip - if ( - current_frame is None - or obj.thumbnail_data is None - or obj.false_positive - or obj.thumbnail_data["frame_time"] != frame_time - ): - continue - - if object_type in self.best_objects: - current_best = self.best_objects[object_type] - now = datetime.datetime.now().timestamp() - # if the object is a higher score than the current best score - # or the current object is older than desired, use the new object - if ( - is_better_thumbnail( - object_type, - current_best.thumbnail_data, - obj.thumbnail_data, - self.camera_config.frame_shape, - ) - or (now - current_best.thumbnail_data["frame_time"]) - > self.camera_config.best_image_timeout - ): - self.best_objects[object_type] = obj - for c in self.callbacks["snapshot"]: - c(self.name, self.best_objects[object_type], frame_name) - else: - self.best_objects[object_type] = obj - for c in self.callbacks["snapshot"]: - c(self.name, self.best_objects[object_type], frame_name) - - for c in self.callbacks["camera_activity"]: - c(self.name, camera_activity) - - # cleanup thumbnail frame cache - current_thumb_frames = { - obj.thumbnail_data["frame_time"] - for obj in tracked_objects.values() - if not obj.false_positive and obj.thumbnail_data is not None - } - current_best_frames = { - obj.thumbnail_data["frame_time"] for obj in self.best_objects.values() - } - thumb_frames_to_delete = [ - t - for t in self.frame_cache.keys() - if t not in current_thumb_frames and t not in current_best_frames - ] - for t in thumb_frames_to_delete: - del self.frame_cache[t] - - with self.current_frame_lock: - self.tracked_objects = tracked_objects - self.motion_boxes = motion_boxes - self.regions = regions - - if current_frame is not None: - self.current_frame_time = frame_time - self._current_frame = np.copy(current_frame) - - if self.previous_frame_id is not None: - self.frame_manager.close(self.previous_frame_id) - - self.previous_frame_id = frame_name - - def shutdown(self) -> None: - for obj in self.tracked_objects.values(): - if not obj.obj_data.get("end_time"): - obj.write_thumbnail_to_disk() +class ManualEventState(str, Enum): + complete = "complete" + start = "start" + end = "end" class TrackedObjectProcessor(threading.Thread): @@ -449,14 +63,13 @@ class TrackedObjectProcessor(threading.Thread): self.config_enabled_subscriber = ConfigSubscriber("config/enabled/") self.requestor = InterProcessRequestor() - self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) + self.detection_publisher = DetectionPublisher(DetectionTypeEnum.all) self.event_sender = EventUpdatePublisher() self.event_end_subscriber = EventEndSubscriber() - self.sub_label_subscriber = EventMetadataSubscriber( - EventMetadataTypeEnum.sub_label - ) + self.sub_label_subscriber = EventMetadataSubscriber(EventMetadataTypeEnum.all) self.camera_activity: dict[str, dict[str, any]] = {} + self.ongoing_manual_events: dict[str, str] = {} # { # 'zone_name': { @@ -677,7 +290,7 @@ class TrackedObjectProcessor(threading.Thread): def get_current_frame( self, camera: str, draw_options: dict[str, any] = {} - ) -> Optional[np.ndarray]: + ) -> np.ndarray | None: if camera == "birdseye": return self.frame_manager.get( "birdseye", @@ -733,6 +346,96 @@ class TrackedObjectProcessor(threading.Thread): return True + def create_manual_event(self, payload: tuple) -> None: + ( + frame_time, + camera_name, + label, + event_id, + include_recording, + score, + sub_label, + duration, + source_type, + draw, + ) = payload + + # save the snapshot image + self.camera_states[camera_name].save_manual_event_image(event_id, label, draw) + end_time = frame_time + duration if duration is not None else None + + # send event to event maintainer + self.event_sender.publish( + ( + EventTypeEnum.api, + EventStateEnum.start, + camera_name, + "", + { + "id": event_id, + "label": label, + "sub_label": sub_label, + "score": score, + "camera": camera_name, + "start_time": frame_time + - self.config.cameras[camera_name].record.event_pre_capture, + "end_time": end_time, + "has_clip": self.config.cameras[camera_name].record.enabled + and include_recording, + "has_snapshot": True, + "type": source_type, + }, + ) + ) + + if source_type == "api": + self.ongoing_manual_events[event_id] = camera_name + self.detection_publisher.publish( + ( + camera_name, + frame_time, + { + "state": ( + ManualEventState.complete + if end_time + else ManualEventState.start + ), + "label": f"{label}: {sub_label}" if sub_label else label, + "event_id": event_id, + "end_time": end_time, + }, + ), + DetectionTypeEnum.api.value, + ) + + def end_manual_event(self, payload: tuple) -> None: + (event_id, end_time) = payload + + self.event_sender.publish( + ( + EventTypeEnum.api, + EventStateEnum.end, + None, + "", + {"id": event_id, "end_time": end_time}, + ) + ) + + if event_id in self.ongoing_manual_events: + self.detection_publisher.publish( + ( + self.ongoing_manual_events[event_id], + end_time, + { + "state": ManualEventState.end, + "event_id": event_id, + "end_time": end_time, + }, + ), + DetectionTypeEnum.api.value, + ) + self.ongoing_manual_events.pop(event_id) + def force_end_all_events(self, camera: str, camera_state: CameraState): """Ends all active events on camera when disabling.""" last_frame_name = camera_state.previous_frame_id @@ -792,15 +495,22 @@ class TrackedObjectProcessor(threading.Thread): # check for sub label updates while True: - (topic, payload) = self.sub_label_subscriber.check_for_update( - timeout=0.1 + (raw_topic, payload) = self.sub_label_subscriber.check_for_update( + timeout=0 ) - if not topic: + if not raw_topic: break - (event_id, sub_label, score) = payload - self.set_sub_label(event_id, sub_label, score) + topic = str(raw_topic) + + if topic.endswith(EventMetadataTypeEnum.sub_label.value): + (event_id, sub_label, score) = payload + self.set_sub_label(event_id, sub_label, score) + elif topic.endswith(EventMetadataTypeEnum.manual_event_create.value): + self.create_manual_event(payload) + elif topic.endswith(EventMetadataTypeEnum.manual_event_end.value): + self.end_manual_event(payload) try: ( @@ -839,7 +549,8 @@ class TrackedObjectProcessor(threading.Thread): tracked_objects, motion_boxes, regions, - ) + ), + DetectionTypeEnum.video.value, ) # cleanup event finished queue diff --git a/process_clip.py b/process_clip.py index 7ef9f4c75..46bbc2c91 100644 --- a/process_clip.py +++ b/process_clip.py @@ -15,8 +15,8 @@ sys.path.append("/workspace/frigate") from frigate.config import FrigateConfig # noqa: E402 from frigate.motion import MotionDetector # noqa: E402 from frigate.object_detection import LocalObjectDetector # noqa: E402 -from frigate.object_processing import CameraState # noqa: E402 from frigate.track.centroid_tracker import CentroidTracker # noqa: E402 +from frigate.track.object_processing import CameraState # noqa: E402 from frigate.util import ( # noqa: E402 EventsPerSecond, SharedMemoryFrameManager, diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index ae9fd6197..f7121d40e 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -378,7 +378,9 @@ export default function LivePlayer({ {[ ...new Set([ ...(objects || []).map(({ label, sub_label }) => - label.endsWith("verified") ? sub_label : label, + label.endsWith("verified") + ? sub_label + : label.replaceAll("_", " "), ), ]), ] From 4806d30406f1b3a2473c9b06077845b446d9bdbf Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 12 Mar 2025 07:09:09 -0500 Subject: [PATCH 54/82] Bugfixes (#17112) * ensure disabled and offline messages don't overlay each other * clean up enabled logic * fix on demand recording button * clean up --- frigate/video.py | 27 ++++++++++++------------ web/src/components/player/LivePlayer.tsx | 18 +--------------- web/src/types/frigateConfig.ts | 15 ++++++++----- web/src/views/live/LiveCameraView.tsx | 2 +- 4 files changed, 25 insertions(+), 37 deletions(-) diff --git a/frigate/video.py b/frigate/video.py index 89543e21a..abf490a72 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -606,23 +606,24 @@ def process_frames( startup_scan = True stationary_frame_counter = 0 + camera_enabled = True region_min_size = get_min_region_size(model_config) - prev_enabled = None - while not stop_event.is_set(): - _, enabled_config = enabled_config_subscriber.check_for_update() - current_enabled = ( - enabled_config.enabled - if enabled_config - else (prev_enabled if prev_enabled is not None else True) - ) - if prev_enabled is None: - prev_enabled = current_enabled + _, updated_enabled_config = enabled_config_subscriber.check_for_update() - if prev_enabled and not current_enabled and camera_metrics.frame_queue.empty(): + if updated_enabled_config: + prev_enabled = camera_enabled + camera_enabled = updated_enabled_config.enabled + + if ( + not camera_enabled + and prev_enabled != camera_enabled + and camera_metrics.frame_queue.empty() + ): logger.debug(f"Camera {camera_name} disabled, clearing tracked objects") + prev_enabled = camera_enabled # Clear norfair's dictionaries object_tracker.tracked_objects.clear() @@ -638,9 +639,7 @@ def process_frames( for tracker in object_tracker.default_tracker.values(): tracker.tracked_objects = [] - prev_enabled = current_enabled - - if not current_enabled: + if not camera_enabled: time.sleep(0.1) continue diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index f7121d40e..c34f4c94c 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -301,22 +301,6 @@ export default function LivePlayer({ player = ; } - // if (cameraConfig.name == "lpr") - // console.log( - // cameraConfig.name, - // "enabled", - // cameraEnabled, - // "prev enabled", - // prevCameraEnabledRef.current, - // "offline", - // offline, - // "show still", - // showStillWithoutActivity, - // "live ready", - // liveReady, - // player, - // ); - return (
- {offline && !showStillWithoutActivity && ( + {offline && !showStillWithoutActivity && cameraEnabled && (

Stream offline

diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index e468c534f..9b3d60606 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -155,15 +155,20 @@ export interface CameraConfig { record: { enabled: boolean; enabled_in_config: boolean; - events: { - objects: string[] | null; + alerts: { post_capture: number; pre_capture: number; - required_zones: string[]; retain: { - default: number; + days: number; + mode: string; + }; + }; + detections: { + post_capture: number; + pre_capture: number; + retain: { + days: number; mode: string; - objects: Record; }; }; expire_interval: number; diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 15dea59d6..f9cc3cb42 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -1013,7 +1013,7 @@ function FrigateCameraFeatures({
Started manual on-demand recording.
- {!camera.record.enabled || camera.record.retain.days == 0 ? ( + {!camera.record.enabled || camera.record.alerts.retain.days == 0 ? (
Since recording is disabled or restricted in the config for this camera, only a snapshot will be saved. From f305ff3a5a5823e5f460d479f093e3dc081f9768 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 12 Mar 2025 07:58:07 -0600 Subject: [PATCH 55/82] Update hardware recommendation (#17111) * Update hardware recommendation * re-organize information * Broaden introduction * Update index.md * Update hardware.md * Update hardware.md * Update hardware.md * Update index.md * Update index.md * Update hardware.md --- docs/docs/frigate/hardware.md | 93 +++++++++++++++++++++++++---------- docs/docs/frigate/index.md | 2 +- 2 files changed, 68 insertions(+), 27 deletions(-) diff --git a/docs/docs/frigate/hardware.md b/docs/docs/frigate/hardware.md index f7fdba0fd..c9bfe16d6 100644 --- a/docs/docs/frigate/hardware.md +++ b/docs/docs/frigate/hardware.md @@ -21,23 +21,77 @@ I may earn a small commission for my endorsement, recommendation, testimonial, o ## Server -My current favorite is the Beelink EQ13 because of the efficient N100 CPU and dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Google Coral. I may earn a small commission for my endorsement, recommendation, testimonial, or link to any products or services from this website. +My current favorite is the Beelink EQ13 because of the efficient N100 CPU and dual NICs that allow you to setup a dedicated private network for your cameras where they can be blocked from accessing the internet. There are many used workstation options on eBay that work very well. Anything with an Intel CPU and capable of running Debian should work fine. As a bonus, you may want to look for devices with a M.2 or PCIe express slot that is compatible with the Hailo8 or Google Coral. I may earn a small commission for my endorsement, recommendation, testimonial, or link to any products or services from this website. -| Name | Coral Inference Speed | Coral Compatibility | Notes | -| ------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------- | ----------------------------------------------------------------------------------------- | -| Beelink EQ13 (Amazon) | 5-10ms | USB | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. | +| Name | Notes | +| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Beelink EQ13 (Amazon) | Dual gigabit NICs for easy isolated camera network. Easily handles several 1080p cameras. | ## Detectors -A detector is a device which is optimized for running inferences efficiently to detect objects. Using a recommended detector means there will be less latency between detections and more detections can be run per second. Frigate is designed around the expectation that a detector is used to achieve very low inference speeds. Offloading TensorFlow to a detector is an order of magnitude faster and will reduce your CPU load dramatically. As of 0.12, Frigate supports a handful of different detector types with varying inference speeds and performance. +A detector is a device which is optimized for running inferences efficiently to detect objects. Using a recommended detector means there will be less latency between detections and more detections can be run per second. Frigate is designed around the expectation that a detector is used to achieve very low inference speeds. Offloading TensorFlow to a detector is an order of magnitude faster and will reduce your CPU load dramatically. + +:::info + +Frigate supports multiple different detectors that work on different types of hardware: + +**Most Hardware** + +- [Hailo](#hailo-8): The Hailo8 and Hailo8L AI Acceleration module is available in m.2 format with a HAT for RPi devices offering a wide range of compatibility with devices. + - [Supports many model architectures](../../configuration/object_detectors#configuration) + - Runs best with tiny or small size models + +- [Google Coral EdgeTPU](#google-coral-tpu): The Google Coral EdgeTPU is available in USB and m.2 format allowing for a wide range of compatibility with devices. + - [Supports primarily ssdlite and mobilenet model architectures](../../configuration/object_detectors#edge-tpu-detector) + +**AMD** + +- [ROCm](#amd-gpus): ROCm can run on AMD Discrete GPUs to provide efficient object detection + - [Supports limited model architectures](../../configuration/object_detectors#supported-models-1) + - Runs best on discrete AMD GPUs + +**Intel** + +- [OpenVino](#openvino): OpenVino can run on Intel Arc GPUs, Intel integrated GPUs, and Intel CPUs to provide efficient object detection. + - [Supports majority of model architectures](../../configuration/object_detectors#supported-models) + - Runs best with tiny, small, or medium models + +**Nvidia** + +- [TensortRT](#tensorrt---nvidia-gpu): TensorRT can run on Nvidia GPUs and Jetson devices. + - [Supports majority of model architectures via ONNX](../../configuration/object_detectors#supported-models-2) + - Runs well with any size models including large + +**Rockchip** + +- [RKNN](#rockchip-platform): RKNN models can run on Rockchip devices with included NPUs to provide efficient object detection. + - [Supports limited model architectures](../../configuration/object_detectors#choosing-a-model) + - Runs best with tiny or small size models + - Runs efficiently on low power hardware + +::: + +### Hailo-8 + + +Frigate supports both the Hailo-8 and Hailo-8L AI Acceleration Modules on compatible hardware platforms—including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate automatically identifies your hardware type and selects the appropriate default model when a custom model isn’t provided. + +**Default Model Configuration:** +- **Hailo-8L:** Default model is **YOLOv6n**. +- **Hailo-8:** Default model is **YOLOv6n**. + +In real-world deployments, even with multiple cameras running concurrently, Frigate has demonstrated consistent performance. Testing on x86 platforms—with dual PCIe lanes—yields further improvements in FPS, throughput, and latency compared to the Raspberry Pi setup. + +| Name | Hailo‑8 Inference Time | Hailo‑8L Inference Time | +| ---------------- | ---------------------- | ----------------------- | +| ssd mobilenet v1 | ~ 6 ms | ~ 10 ms | +| yolov6n | ~ 7 ms | ~ 11 ms | ### Google Coral TPU -It is strongly recommended to use a Google Coral. A $60 device will outperform $2000 CPU. Frigate should work with any supported Coral device from https://coral.ai - -The USB version is compatible with the widest variety of hardware and does not require a driver on the host machine. However, it does lack the automatic throttling features of the other versions. - -The PCIe and M.2 versions require installation of a driver on the host. Follow the instructions for your version from https://coral.ai +Frigate supports both the USB and M.2 versions of the Google Coral. +- The USB version is compatible with the widest variety of hardware and does not require a driver on the host machine. However, it does lack the automatic throttling features of the other versions. +- The PCIe and M.2 versions require installation of a driver on the host. Follow the instructions for your version from https://coral.ai A single Coral can handle many cameras using the default model and will be sufficient for the majority of users. You can calculate the maximum performance of your Coral based on the inference speed reported by Frigate. With an inference speed of 10, your Coral will top out at `1000/10=100`, or 100 frames per second. If your detection fps is regularly getting close to that, you should first consider tuning motion masks. If those are already properly configured, a second Coral may be needed. @@ -92,22 +146,9 @@ Inference speeds will vary greatly depending on the GPU and the model used. With the [rocm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs. -### Hailo-8 - -| Name | Hailo‑8 Inference Time | Hailo‑8L Inference Time | -| --------------- | ---------------------- | ----------------------- | -| ssd mobilenet v1| ~ 6 ms | ~ 10 ms | -| yolov6n | ~ 7 ms | ~ 11 ms | - - -Frigate supports both the Hailo-8 and Hailo-8L AI Acceleration Modules on compatible hardware platforms—including the Raspberry Pi 5 with the PCIe hat from the AI kit. The Hailo detector integration in Frigate automatically identifies your hardware type and selects the appropriate default model when a custom model isn’t provided. - -**Default Model Configuration:** -- **Hailo-8L:** Default model is **YOLOv6n**. -- **Hailo-8:** Default model is **YOLOv6n**. - -In real-world deployments, even with multiple cameras running concurrently, Frigate has demonstrated consistent performance. Testing on x86 platforms—with dual PCIe lanes—yields further improvements in FPS, throughput, and latency compared to the Raspberry Pi setup. - +| Name | YoloV9 Inference Time | YOLO-NAS Inference Time | +| --------------- | --------------------- | ------------------------- | +| AMD 780M | ~ 14 ms | ~ 60 ms | ## Community Supported Detectors diff --git a/docs/docs/frigate/index.md b/docs/docs/frigate/index.md index 73b3305e7..83162022c 100644 --- a/docs/docs/frigate/index.md +++ b/docs/docs/frigate/index.md @@ -6,7 +6,7 @@ slug: / A complete and local NVR designed for Home Assistant 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 strongly recommended. CPU detection should only be used for testing purposes. The Coral will outperform even the best CPUs and can process 100+ FPS with very little overhead. +Use of a [Recommended Detector](/frigate/hardware#detectors) is optional, but strongly recommended. CPU detection should only be used for testing purposes. - Tight integration with Home Assistant via a [custom component](https://github.com/blakeblackshear/frigate-hass-integration) - Designed to minimize resource use and maximize performance by only looking for objects when and where it is necessary From 9e70bddc9d640becf9d058470e52d9afbb6647ec Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 12 Mar 2025 12:54:20 -0600 Subject: [PATCH 56/82] Update go2rtc version (#17119) --- docker/main/Dockerfile | 2 +- .../rootfs/usr/local/go2rtc/create_config.py | 13 -- docs/docs/configuration/advanced.md | 2 +- docs/docs/configuration/camera_specific.md | 2 +- docs/docs/configuration/reference.md | 2 +- docs/docs/configuration/restream.md | 6 +- docs/docs/guides/configuring_go2rtc.md | 107 +++++++------- docs/sidebars.ts | 138 +++++++++--------- 8 files changed, 132 insertions(+), 140 deletions(-) diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 7a0351240..2a7d388bc 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -55,7 +55,7 @@ RUN --mount=type=tmpfs,target=/tmp --mount=type=tmpfs,target=/var/cache/apt \ FROM scratch AS go2rtc ARG TARGETARCH WORKDIR /rootfs/usr/local/go2rtc/bin -ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.2/go2rtc_linux_${TARGETARCH}" go2rtc +ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${TARGETARCH}" go2rtc FROM wget AS tempio ARG TARGETARCH diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 4fe26775e..30b78e1e1 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -69,10 +69,6 @@ elif go2rtc_config["log"].get("format") is None: if go2rtc_config.get("webrtc") is None: go2rtc_config["webrtc"] = {} -# go2rtc should listen on 8555 tcp & udp by default -if go2rtc_config["webrtc"].get("listen") is None: - go2rtc_config["webrtc"]["listen"] = ":8555" - if go2rtc_config["webrtc"].get("candidates") is None: default_candidates = [] # use internal candidate if it was discovered when running through the add-on @@ -84,15 +80,6 @@ if go2rtc_config["webrtc"].get("candidates") is None: go2rtc_config["webrtc"]["candidates"] = default_candidates -# This prevents WebRTC from attempting to establish a connection to the internal -# docker IPs which are not accessible from outside the container itself and just -# wastes time during negotiation. Note that this is only necessary because -# Frigate container doesn't run in host network mode. -if go2rtc_config["webrtc"].get("filter") is None: - go2rtc_config["webrtc"]["filter"] = {"candidates": []} -elif go2rtc_config["webrtc"]["filter"].get("candidates") is None: - go2rtc_config["webrtc"]["filter"]["candidates"] = [] - # sets default RTSP response to be equivalent to ?video=h264,h265&audio=aac # this means user does not need to specify audio codec when using restream # as source for frigate and the integration supports HLS playback diff --git a/docs/docs/configuration/advanced.md b/docs/docs/configuration/advanced.md index b037c0768..c889d2d26 100644 --- a/docs/docs/configuration/advanced.md +++ b/docs/docs/configuration/advanced.md @@ -186,7 +186,7 @@ To do this: ### Custom go2rtc version -Frigate currently includes go2rtc v1.9.2, there may be certain cases where you want to run a different version of go2rtc. +Frigate currently includes go2rtc v1.9.9, there may be certain cases where you want to run a different version of go2rtc. To do this: diff --git a/docs/docs/configuration/camera_specific.md b/docs/docs/configuration/camera_specific.md index fb4ce5714..0cd1efd47 100644 --- a/docs/docs/configuration/camera_specific.md +++ b/docs/docs/configuration/camera_specific.md @@ -219,7 +219,7 @@ go2rtc: - rtspx://192.168.1.1:7441/abcdefghijk ``` -[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#source-rtsp) +[See the go2rtc docs for more information](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#source-rtsp) In the Unifi 2.0 update Unifi Protect Cameras had a change in audio sample rate which causes issues for ffmpeg. The input rate needs to be set for record if used directly with unifi protect. diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 37884259a..9a880aade 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -591,7 +591,7 @@ genai: person: "My special person prompt." # Optional: Restream configuration -# Uses https://github.com/AlexxIT/go2rtc (v1.9.2) +# Uses https://github.com/AlexxIT/go2rtc (v1.9.9) # NOTE: The default go2rtc API port (1984) must be used, # changing this port for the integrated go2rtc instance is not supported. go2rtc: diff --git a/docs/docs/configuration/restream.md b/docs/docs/configuration/restream.md index 0db4ded80..288dc8fd9 100644 --- a/docs/docs/configuration/restream.md +++ b/docs/docs/configuration/restream.md @@ -7,7 +7,7 @@ title: Restream Frigate can restream your video feed as an RTSP feed for other applications such as Home Assistant to utilize it at `rtsp://:8554/`. Port 8554 must be open. [This allows you to use a video feed for detection in Frigate and Home Assistant live view at the same time without having to make two separate connections to the camera](#reduce-connections-to-camera). The video feed is copied from the original video feed directly to avoid re-encoding. This feed does not include any annotation by Frigate. -Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.2) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#configuration) for more advanced configurations and features. +Frigate uses [go2rtc](https://github.com/AlexxIT/go2rtc/tree/v1.9.9) to provide its restream and MSE/WebRTC capabilities. The go2rtc config is hosted at the `go2rtc` in the config, see [go2rtc docs](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#configuration) for more advanced configurations and features. :::note @@ -134,7 +134,7 @@ cameras: ## Handling Complex Passwords -go2rtc expects URL-encoded passwords in the config, [urlencoder.org](https://urlencoder.org) can be used for this purpose. +go2rtc expects URL-encoded passwords in the config, [urlencoder.org](https://urlencoder.org) can be used for this purpose. For example: @@ -156,7 +156,7 @@ See [this comment(https://github.com/AlexxIT/go2rtc/issues/1217#issuecomment-224 ## Advanced Restream Configurations -The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below: +The [exec](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#source-exec) source in go2rtc can be used for custom ffmpeg commands. An example is below: NOTE: The output will need to be passed with two curly braces `{{output}}` diff --git a/docs/docs/guides/configuring_go2rtc.md b/docs/docs/guides/configuring_go2rtc.md index 1a61fd0c5..3b8a8af1f 100644 --- a/docs/docs/guides/configuring_go2rtc.md +++ b/docs/docs/guides/configuring_go2rtc.md @@ -13,7 +13,7 @@ Use of the bundled go2rtc is optional. You can still configure FFmpeg to connect # Setup a go2rtc stream -First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#module-streams), not just rtsp. +First, you will want to configure go2rtc to connect to your camera stream by adding the stream you want to use for live view in your Frigate config file. Avoid changing any other parts of your config at this step. Note that go2rtc supports [many different stream types](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#module-streams), not just rtsp. :::tip @@ -32,69 +32,74 @@ go2rtc: After adding this to the config, restart Frigate and try to watch the live stream for a single camera by clicking on it from the dashboard. It should look much clearer and more fluent than the original jsmpeg stream. - ### What if my video doesn't play? - Check Logs: - - Access the go2rtc logs in the Frigate UI under Logs in the sidebar. - - If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. + + - Access the go2rtc logs in the Frigate UI under Logs in the sidebar. + - If go2rtc is having difficulty connecting to your camera, you should see some error messages in the log. - Check go2rtc Web Interface: if you don't see any errors in the logs, try viewing the camera through go2rtc's web interface. - - Navigate to port 1984 in your browser to access go2rtc's web interface. - - If using Frigate through Home Assistant, enable the web interface at port 1984. - - If using Docker, forward port 1984 before accessing the web interface. - - Click `stream` for the specific camera to see if the camera's stream is being received. + + - Navigate to port 1984 in your browser to access go2rtc's web interface. + - If using Frigate through Home Assistant, enable the web interface at port 1984. + - If using Docker, forward port 1984 before accessing the web interface. + - Click `stream` for the specific camera to see if the camera's stream is being received. - Check Video Codec: - - If the camera stream works in go2rtc but not in your browser, the video codec might be unsupported. - - If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#codecs-madness) in go2rtc documentation. - - If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.2#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. - ```yaml - go2rtc: - streams: - back: - - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 - - "ffmpeg:back#video=h264#hardware" - ``` -- Switch to FFmpeg if needed: - - Some camera streams may need to use the ffmpeg module in go2rtc. This has the downside of slower startup times, but has compatibility with more stream types. - ```yaml - go2rtc: - streams: - back: - - ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 - ``` + - If the camera stream works in go2rtc but not in your browser, the video codec might be unsupported. + - If using H265, switch to H264. Refer to [video codec compatibility](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#codecs-madness) in go2rtc documentation. + - If unable to switch from H265 to H264, or if the stream format is different (e.g., MJPEG), re-encode the video using [FFmpeg parameters](https://github.com/AlexxIT/go2rtc/tree/v1.9.9#source-ffmpeg). It supports rotating and resizing video feeds and hardware acceleration. Keep in mind that transcoding video from one format to another is a resource intensive task and you may be better off using the built-in jsmpeg view. + ```yaml + go2rtc: + streams: + back: + - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 + - "ffmpeg:back#video=h264#hardware" + ``` - - If you can see the video but do not have audio, this is most likely because your camera's audio stream codec is not AAC. - - If possible, update your camera's audio settings to AAC in your camera's firmware. - - If your cameras do not support AAC audio, you will need to tell go2rtc to re-encode the audio to AAC on demand if you want audio. This will use additional CPU and add some latency. To add AAC audio on demand, you can update your go2rtc config as follows: - ```yaml - go2rtc: - streams: - back: - - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 - - "ffmpeg:back#audio=aac" - ``` +- Switch to FFmpeg if needed: - If you need to convert **both** the audio and video streams, you can use the following: + - Some camera streams may need to use the ffmpeg module in go2rtc. This has the downside of slower startup times, but has compatibility with more stream types. - ```yaml - go2rtc: - streams: - back: - - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 - - "ffmpeg:back#video=h264#audio=aac#hardware" - ``` + ```yaml + go2rtc: + streams: + back: + - ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 + ``` - When using the ffmpeg module, you would add AAC audio like this: + - If you can see the video but do not have audio, this is most likely because your camera's audio stream codec is not AAC. + - If possible, update your camera's audio settings to AAC in your camera's firmware. + - If your cameras do not support AAC audio, you will need to tell go2rtc to re-encode the audio to AAC on demand if you want audio. This will use additional CPU and add some latency. To add AAC audio on demand, you can update your go2rtc config as follows: - ```yaml - go2rtc: - streams: - back: - - "ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2#video=copy#audio=copy#audio=aac#hardware" - ``` + ```yaml + go2rtc: + streams: + back: + - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 + - "ffmpeg:back#audio=aac" + ``` + + If you need to convert **both** the audio and video streams, you can use the following: + + ```yaml + go2rtc: + streams: + back: + - rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2 + - "ffmpeg:back#video=h264#audio=aac#hardware" + ``` + + When using the ffmpeg module, you would add AAC audio like this: + + ```yaml + go2rtc: + streams: + back: + - "ffmpeg:rtsp://user:password@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2#video=copy#audio=copy#audio=aac#hardware" + ``` :::warning diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 0c25e4eb7..bffb54349 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -1,106 +1,106 @@ -import type { SidebarsConfig, } from '@docusaurus/plugin-content-docs'; -import { PropSidebarItemLink } from '@docusaurus/plugin-content-docs'; -import frigateHttpApiSidebar from './docs/integrations/api/sidebar'; +import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"; +import { PropSidebarItemLink } from "@docusaurus/plugin-content-docs"; +import frigateHttpApiSidebar from "./docs/integrations/api/sidebar"; const sidebars: SidebarsConfig = { docs: { Frigate: [ - 'frigate/index', - 'frigate/hardware', - 'frigate/installation', - 'frigate/camera_setup', - 'frigate/video_pipeline', - 'frigate/glossary', + "frigate/index", + "frigate/hardware", + "frigate/installation", + "frigate/camera_setup", + "frigate/video_pipeline", + "frigate/glossary", ], Guides: [ - 'guides/getting_started', - 'guides/configuring_go2rtc', - 'guides/ha_notifications', - 'guides/ha_network_storage', - 'guides/reverse_proxy', + "guides/getting_started", + "guides/configuring_go2rtc", + "guides/ha_notifications", + "guides/ha_network_storage", + "guides/reverse_proxy", ], Configuration: { - 'Configuration Files': [ - 'configuration/index', - 'configuration/reference', + "Configuration Files": [ + "configuration/index", + "configuration/reference", { - type: 'link', - label: 'Go2RTC Configuration Reference', - href: 'https://github.com/AlexxIT/go2rtc/tree/v1.9.2#configuration', + type: "link", + label: "Go2RTC Configuration Reference", + href: "https://github.com/AlexxIT/go2rtc/tree/v1.9.9#configuration", } as PropSidebarItemLink, ], Detectors: [ - 'configuration/object_detectors', - 'configuration/audio_detectors', + "configuration/object_detectors", + "configuration/audio_detectors", ], Classifiers: [ - 'configuration/semantic_search', - 'configuration/genai', - 'configuration/face_recognition', - 'configuration/license_plate_recognition', + "configuration/semantic_search", + "configuration/genai", + "configuration/face_recognition", + "configuration/license_plate_recognition", ], Cameras: [ - 'configuration/cameras', - 'configuration/review', - 'configuration/record', - 'configuration/snapshots', - 'configuration/motion_detection', - 'configuration/birdseye', - 'configuration/live', - 'configuration/restream', - 'configuration/autotracking', - 'configuration/camera_specific', + "configuration/cameras", + "configuration/review", + "configuration/record", + "configuration/snapshots", + "configuration/motion_detection", + "configuration/birdseye", + "configuration/live", + "configuration/restream", + "configuration/autotracking", + "configuration/camera_specific", ], Objects: [ - 'configuration/object_filters', - 'configuration/masks', - 'configuration/zones', - 'configuration/objects', - 'configuration/stationary_objects', + "configuration/object_filters", + "configuration/masks", + "configuration/zones", + "configuration/objects", + "configuration/stationary_objects", ], - 'Extra Configuration': [ - 'configuration/authentication', - 'configuration/notifications', - 'configuration/hardware_acceleration', - 'configuration/ffmpeg_presets', + "Extra Configuration": [ + "configuration/authentication", + "configuration/notifications", + "configuration/hardware_acceleration", + "configuration/ffmpeg_presets", "configuration/pwa", - 'configuration/tls', - 'configuration/advanced', + "configuration/tls", + "configuration/advanced", ], }, Integrations: [ - 'integrations/plus', - 'integrations/home-assistant', + "integrations/plus", + "integrations/home-assistant", // This is the HTTP API generated by OpenAPI { - type: 'category', - label: 'HTTP API', + type: "category", + label: "HTTP API", link: { - type: 'generated-index', - title: 'Frigate HTTP API', - description: 'HTTP API', - slug: '/integrations/api/frigate-http-api', + type: "generated-index", + title: "Frigate HTTP API", + description: "HTTP API", + slug: "/integrations/api/frigate-http-api", }, items: frigateHttpApiSidebar, }, - 'integrations/mqtt', - 'configuration/metrics', - 'integrations/third_party_extensions', + "integrations/mqtt", + "configuration/metrics", + "integrations/third_party_extensions", ], - 'Frigate+': [ - 'plus/index', - 'plus/first_model', - 'plus/improving_model', - 'plus/faq', + "Frigate+": [ + "plus/index", + "plus/first_model", + "plus/improving_model", + "plus/faq", ], Troubleshooting: [ - 'troubleshooting/faqs', - 'troubleshooting/recordings', - 'troubleshooting/edgetpu', + "troubleshooting/faqs", + "troubleshooting/recordings", + "troubleshooting/edgetpu", ], Development: [ - 'development/contributing', - 'development/contributing-boards', + "development/contributing", + "development/contributing-boards", ], }, }; From 636080261243d7e0103d9b4e40ac332fe4eed4a5 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 12 Mar 2025 15:38:28 -0500 Subject: [PATCH 57/82] Use identifier field for unknown license plates (#17123) * backend * backend fixes * api for search queries * frontend * docs * add filterable scroll list to more filters pane for identifiers * always publish identifier --- .../license_plate_recognition.md | 9 +- docs/docs/integrations/mqtt.md | 8 +- frigate/api/app.py | 33 +++++ .../api/defs/query/events_query_parameters.py | 2 + frigate/api/event.py | 60 ++++++++ frigate/camera/state.py | 9 +- frigate/comms/event_metadata_updater.py | 1 + .../common/license_plate/mixin.py | 12 +- frigate/events/maintainer.py | 6 + frigate/track/object_processing.py | 38 +++++ frigate/track/tracked_object.py | 3 + .../overlay/detail/SearchDetailDialog.tsx | 23 +++ .../overlay/dialog/SearchFilterDialog.tsx | 133 +++++++++++++++++- web/src/pages/Explore.tsx | 2 + web/src/types/search.ts | 4 + web/src/views/search/SearchView.tsx | 4 +- 16 files changed, 332 insertions(+), 15 deletions(-) diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index 3fe1ee852..f8b9030ff 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -3,16 +3,16 @@ id: license_plate_recognition title: License Plate Recognition (LPR) --- -Frigate can recognize license plates on vehicles and automatically add the detected characters or recognized 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. +Frigate can recognize license plates on vehicles and automatically add the detected characters to the `identifier` 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. LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. However, LPR does not run on stationary vehicles. -When a plate is recognized, the detected characters or recognized name is: +When a plate is recognized, the recognized name is: -- Added as a `sub_label` to the `car` tracked object. +- Added to the `car` tracked object as a `sub_label` (if known) or the `identifier` field (if unknown) - Viewable in the Review Item Details pane in Review and the Tracked Object Details pane in Explore. - Filterable through the More Filters menu in Explore. -- Published via the `frigate/events` MQTT topic as a `sub_label` for the tracked object. +- Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `identifier` (unknown) for the tracked object. ## Model Requirements @@ -71,6 +71,7 @@ Fine-tune the LPR feature using these optional parameters: - **`known_plates`**: List of strings or regular expressions that assign custom a `sub_label` to `car` objects when a recognized plate matches a known value. - These labels appear in the UI, filters, and notifications. + - Unknown plates are still saved but are added to the `identifier` field rather than the `sub_label`. - **`match_distance`**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. - For example, setting `match_distance: 1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`. - This parameter will _not_ operate on known plates that are defined as regular expressions. You should define the full string of your plate in `known_plates` in order to use `match_distance`. diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index fc8888e40..bcdbe7046 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -54,7 +54,9 @@ Message published for each changed tracked object. The first message is publishe }, // attributes with top score that have been identified on the object at any point "current_attributes": [], // detailed data about the current attributes in this frame "current_estimated_speed": 0.71, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled - "velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled + "velocity_angle": 180, // direction of travel relative to the frame for objects moving through zones with speed estimation enabled + "identifier": "ABC12345", // an identifier for this object - in this case, an unrecognized license plate + "identifier_score": 0.933451 }, "after": { "id": "1607123955.475377-mxklsc", @@ -93,7 +95,9 @@ Message published for each changed tracked object. The first message is publishe } ], "current_estimated_speed": 0.77, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled - "velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled + "velocity_angle": 180, // direction of travel relative to the frame for objects moving through zones with speed estimation enabled + "identifier": "ABC12345", // an identifier for this object - in this case, an unrecognized license plate + "identifier_score": 0.933451 } } ``` diff --git a/frigate/api/app.py b/frigate/api/app.py index 5ce90130f..d9a57a3c1 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -619,6 +619,39 @@ def get_sub_labels(split_joined: Optional[int] = None): return JSONResponse(content=sub_labels) +@router.get("/identifiers") +def get_identifiers(split_joined: Optional[int] = None): + try: + events = Event.select(Event.data).distinct() + except Exception: + return JSONResponse( + content=({"success": False, "message": "Failed to get identifiers"}), + status_code=404, + ) + + identifiers = [] + for e in events: + if e.data is not None and "identifier" in e.data: + identifiers.append(e.data["identifier"]) + + while None in identifiers: + identifiers.remove(None) + + if split_joined: + original_identifiers = identifiers.copy() + for identifier in original_identifiers: + if identifier and "," in identifier: + identifiers.remove(identifier) + parts = identifier.split(",") + for part in parts: + if part.strip() not in identifiers: + identifiers.append(part.strip()) + + identifiers = list(set(identifiers)) + identifiers.sort() + return JSONResponse(content=identifiers) + + @router.get("/timeline") def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = None): clauses = [] diff --git a/frigate/api/defs/query/events_query_parameters.py b/frigate/api/defs/query/events_query_parameters.py index 01c79abb0..9f73d8583 100644 --- a/frigate/api/defs/query/events_query_parameters.py +++ b/frigate/api/defs/query/events_query_parameters.py @@ -27,6 +27,7 @@ class EventsQueryParams(BaseModel): max_score: Optional[float] = None min_speed: Optional[float] = None max_speed: Optional[float] = None + identifier: Optional[str] = "all" is_submitted: Optional[int] = None min_length: Optional[float] = None max_length: Optional[float] = None @@ -55,6 +56,7 @@ class EventsSearchQueryParams(BaseModel): max_score: Optional[float] = None min_speed: Optional[float] = None max_speed: Optional[float] = None + identifier: Optional[str] = "all" sort: Optional[str] = None diff --git a/frigate/api/event.py b/frigate/api/event.py index 91651313d..e9cf2fea4 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -101,6 +101,7 @@ def events(params: EventsQueryParams = Depends()): min_length = params.min_length max_length = params.max_length event_id = params.event_id + identifier = params.identifier sort = params.sort @@ -158,6 +159,32 @@ def events(params: EventsQueryParams = Depends()): sub_label_clause = reduce(operator.or_, sub_label_clauses) clauses.append((sub_label_clause)) + if identifier != "all": + # use matching so joined identifiers are included + # for example an identifier 'ABC123' would get events + # with identifiers 'ABC123' and 'ABC123, XYZ789' + identifier_clauses = [] + filtered_identifiers = identifier.split(",") + + if "None" in filtered_identifiers: + filtered_identifiers.remove("None") + identifier_clauses.append((Event.data["identifier"].is_null())) + + for identifier in filtered_identifiers: + # Exact matching plus list inclusion + identifier_clauses.append( + (Event.data["identifier"].cast("text") == identifier) + ) + identifier_clauses.append( + (Event.data["identifier"].cast("text") % f"*{identifier},*") + ) + identifier_clauses.append( + (Event.data["identifier"].cast("text") % f"*, {identifier}*") + ) + + identifier_clause = reduce(operator.or_, identifier_clauses) + clauses.append((identifier_clause)) + if zones != "all": # use matching so events with multiple zones # still match on a search where any zone matches @@ -340,6 +367,8 @@ def events_explore(limit: int = 10): "average_estimated_speed", "velocity_angle", "path_data", + "identifier", + "identifier_score", ] }, "event_count": label_counts[event.label], @@ -397,6 +426,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) has_clip = params.has_clip has_snapshot = params.has_snapshot is_submitted = params.is_submitted + identifier = params.identifier # for similarity search event_id = params.event_id @@ -466,6 +496,32 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) event_filters.append((reduce(operator.or_, zone_clauses))) + if identifier != "all": + # use matching so joined identifiers are included + # for example an identifier 'ABC123' would get events + # with identifiers 'ABC123' and 'ABC123, XYZ789' + identifier_clauses = [] + filtered_identifiers = identifier.split(",") + + if "None" in filtered_identifiers: + filtered_identifiers.remove("None") + identifier_clauses.append((Event.data["identifier"].is_null())) + + for identifier in filtered_identifiers: + # Exact matching plus list inclusion + identifier_clauses.append( + (Event.data["identifier"].cast("text") == identifier) + ) + identifier_clauses.append( + (Event.data["identifier"].cast("text") % f"*{identifier},*") + ) + identifier_clauses.append( + (Event.data["identifier"].cast("text") % f"*, {identifier}*") + ) + + identifier_clause = reduce(operator.or_, identifier_clauses) + event_filters.append((identifier_clause)) + if after: event_filters.append((Event.start_time > after)) @@ -627,6 +683,8 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) "average_estimated_speed", "velocity_angle", "path_data", + "identifier", + "identifier_score", ] } @@ -681,6 +739,7 @@ def events_summary(params: EventsSummaryQueryParams = Depends()): Event.camera, Event.label, Event.sub_label, + Event.data, fn.strftime( "%Y-%m-%d", fn.datetime( @@ -695,6 +754,7 @@ def events_summary(params: EventsSummaryQueryParams = Depends()): Event.camera, Event.label, Event.sub_label, + Event.data, (Event.start_time + seconds_offset).cast("int") / (3600 * 24), Event.zones, ) diff --git a/frigate/camera/state.py b/frigate/camera/state.py index dfd6744e2..2ffa54798 100644 --- a/frigate/camera/state.py +++ b/frigate/camera/state.py @@ -137,12 +137,13 @@ class CameraState: # draw the bounding boxes on the frame box = obj["box"] text = ( - obj["label"] + obj["sub_label"][0] if ( - not obj.get("sub_label") - or not is_label_printable(obj["sub_label"][0]) + obj.get("sub_label") and is_label_printable(obj["sub_label"][0]) ) - else obj["sub_label"][0] + else obj.get("identifier", [None])[0] + if (obj.get("identifier") and obj["identifier"][0]) + else obj["label"] ) draw_box_with_label( frame_copy, diff --git a/frigate/comms/event_metadata_updater.py b/frigate/comms/event_metadata_updater.py index 3342182c3..c5881e686 100644 --- a/frigate/comms/event_metadata_updater.py +++ b/frigate/comms/event_metadata_updater.py @@ -14,6 +14,7 @@ class EventMetadataTypeEnum(str, Enum): manual_event_end = "manual_event_end" regenerate_description = "regenerate_description" sub_label = "sub_label" + identifier = "identifier" class EventMetadataPublisher(Publisher): diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index c74949d9c..37530b205 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -1054,13 +1054,19 @@ class LicensePlateProcessingMixin: for plate in plates ) ), - top_plate, + None, ) - # Send the result to the API + # If it's a known plate, publish to sub_label + if sub_label is not None: + self.sub_label_publisher.publish( + EventMetadataTypeEnum.sub_label, (id, sub_label, avg_confidence) + ) + self.sub_label_publisher.publish( - EventMetadataTypeEnum.sub_label, (id, sub_label, avg_confidence) + EventMetadataTypeEnum.identifier, (id, top_plate, avg_confidence) ) + self.detected_license_plates[id] = { "plate": top_plate, "char_confidences": top_char_confidences, diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index 5cfa7c716..947763f40 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -27,6 +27,7 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool: or prev_event["average_estimated_speed"] != current_event["average_estimated_speed"] or prev_event["velocity_angle"] != current_event["velocity_angle"] + or prev_event["identifier"] != current_event["identifier"] or prev_event["path_data"] != current_event["path_data"] ): return True @@ -226,6 +227,11 @@ class EventProcessor(threading.Thread): event[Event.sub_label] = event_data["sub_label"][0] event[Event.data]["sub_label_score"] = event_data["sub_label"][1] + # only overwrite the identifier in the database if it's set + if event_data.get("identifier") is not None: + event[Event.data]["identifier"] = event_data["identifier"][0] + event[Event.data]["identifier_score"] = event_data["identifier"][1] + ( Event.insert(event) .on_conflict( diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index ddac2b588..56dd59110 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -346,6 +346,41 @@ class TrackedObjectProcessor(threading.Thread): return True + def set_identifier( + self, event_id: str, identifier: str | None, score: float | None + ) -> None: + """Update identifier for given event id.""" + tracked_obj: TrackedObject = None + + for state in self.camera_states.values(): + tracked_obj = state.tracked_objects.get(event_id) + + if tracked_obj is not None: + break + + try: + event: Event = Event.get(Event.id == event_id) + except DoesNotExist: + event = None + + if not tracked_obj and not event: + return + + if tracked_obj: + tracked_obj.obj_data["identifier"] = (identifier, score) + + if event: + data = event.data + data["identifier"] = identifier + if identifier is None: + data["identifier_score"] = None + elif score is not None: + data["identifier_score"] = score + event.data = data + event.save() + + return True + def create_manual_event(self, payload: tuple) -> None: ( frame_time, @@ -507,6 +542,9 @@ class TrackedObjectProcessor(threading.Thread): if topic.endswith(EventMetadataTypeEnum.sub_label.value): (event_id, sub_label, score) = payload self.set_sub_label(event_id, sub_label, score) + if topic.endswith(EventMetadataTypeEnum.identifier.value): + (event_id, identifier, score) = payload + self.set_identifier(event_id, identifier, score) elif topic.endswith(EventMetadataTypeEnum.manual_event_create.value): self.create_manual_event(payload) elif topic.endswith(EventMetadataTypeEnum.manual_event_end.value): diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index f1eb29328..9c19595b5 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -153,6 +153,8 @@ class TrackedObject: "current_estimated_speed": self.current_estimated_speed, "velocity_angle": self.velocity_angle, "path_data": self.path_data, + "identifier": obj_data.get("identifier"), + "identifier_score": obj_data.get("identifier_score"), } thumb_update = True @@ -365,6 +367,7 @@ class TrackedObject: "average_estimated_speed": self.average_estimated_speed, "velocity_angle": self.velocity_angle, "path_data": self.path_data, + "identifier": self.obj_data.get("identifier"), } return event diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index c94c2cd2d..7f69f2ca2 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -333,6 +333,18 @@ function ObjectDetailsTab({ } }, [search]); + const identifierScore = useMemo(() => { + if (!search) { + return undefined; + } + + if (search.data.identifier && search.data?.identifier_score) { + return Math.round((search.data?.identifier_score ?? 0) * 100); + } else { + return undefined; + } + }, [search]); + const averageEstimatedSpeed = useMemo(() => { if (!search || !search.data?.average_estimated_speed) { return undefined; @@ -538,6 +550,17 @@ function ObjectDetailsTab({
+ {search?.data.identifier && ( +
+
Identifier
+
+
+ {search.data.identifier}{" "} + {identifierScore && ` (${identifierScore}%)`} +
+
+
+ )}
diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx index 23deee531..59b30f82f 100644 --- a/web/src/components/overlay/dialog/SearchFilterDialog.tsx +++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx @@ -33,6 +33,14 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { LuCheck } from "react-icons/lu"; type SearchFilterDialogProps = { config?: FrigateConfig; @@ -77,7 +85,8 @@ export default function SearchFilterDialog({ (currentFilter.max_score ?? 1) < 1 || (currentFilter.max_speed ?? 150) < 150 || (currentFilter.zones?.length ?? 0) > 0 || - (currentFilter.sub_labels?.length ?? 0) > 0), + (currentFilter.sub_labels?.length ?? 0) > 0 || + (currentFilter.identifier?.length ?? 0) > 0), [currentFilter], ); @@ -119,6 +128,12 @@ export default function SearchFilterDialog({ setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels }) } /> + + setCurrentFilter({ ...currentFilter, identifier: identifiers }) + } + /> @@ -830,3 +846,118 @@ export function SnapshotClipFilterContent({
); } + +type IdentifierFilterContentProps = { + identifiers: string[] | undefined; + setIdentifiers: (identifiers: string[] | undefined) => void; +}; + +export function IdentifierFilterContent({ + identifiers, + setIdentifiers, +}: IdentifierFilterContentProps) { + const { data: allIdentifiers, error } = useSWR("identifiers", { + revalidateOnFocus: false, + }); + + const [selectedIdentifiers, setSelectedIdentifiers] = useState( + identifiers || [], + ); + const [inputValue, setInputValue] = useState(""); + + useEffect(() => { + if (identifiers) { + setSelectedIdentifiers(identifiers); + } else { + setSelectedIdentifiers([]); + } + }, [identifiers]); + + const handleSelect = (identifier: string) => { + const newSelected = selectedIdentifiers.includes(identifier) + ? selectedIdentifiers.filter((id) => id !== identifier) // Deselect + : [...selectedIdentifiers, identifier]; // Select + + setSelectedIdentifiers(newSelected); + if (newSelected.length === 0) { + setIdentifiers(undefined); // Clear filter if no identifiers selected + } else { + setIdentifiers(newSelected); + } + }; + + if (!allIdentifiers || allIdentifiers.length === 0) { + return null; + } + + const filteredIdentifiers = + allIdentifiers?.filter((id) => + id.toLowerCase().includes(inputValue.toLowerCase()), + ) || []; + + return ( +
+ +
Identifiers
+ {error ? ( +

Failed to load identifiers

+ ) : !allIdentifiers ? ( +

Loading identifiers...

+ ) : ( + <> + + + + {filteredIdentifiers.length === 0 && inputValue && ( + No identifiers found. + )} + {filteredIdentifiers.map((identifier) => ( + handleSelect(identifier)} + className="cursor-pointer" + > + + {identifier} + + ))} + + + {selectedIdentifiers.length > 0 && ( +
+ {selectedIdentifiers.map((id) => ( + + {id} + + + ))} +
+ )} + + )} +

+ Select one or more identifiers from the list. +

+
+ ); +} diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index af23c18f4..cf24ff70d 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -105,6 +105,7 @@ export default function Explore() { cameras: searchSearchParams["cameras"], labels: searchSearchParams["labels"], sub_labels: searchSearchParams["sub_labels"], + identifier: searchSearchParams["identifier"], zones: searchSearchParams["zones"], before: searchSearchParams["before"], after: searchSearchParams["after"], @@ -140,6 +141,7 @@ export default function Explore() { cameras: searchSearchParams["cameras"], labels: searchSearchParams["labels"], sub_labels: searchSearchParams["sub_labels"], + identifier: searchSearchParams["identifier"], zones: searchSearchParams["zones"], before: searchSearchParams["before"], after: searchSearchParams["after"], diff --git a/web/src/types/search.ts b/web/src/types/search.ts index cef7f6aff..90bcd54d7 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -58,6 +58,8 @@ export type SearchResult = { average_estimated_speed: number; velocity_angle: number; path_data: [number[], number][]; + identifier?: string; + identifier_score?: number; }; }; @@ -66,6 +68,7 @@ export type SearchFilter = { cameras?: string[]; labels?: string[]; sub_labels?: string[]; + identifier?: string[]; zones?: string[]; before?: number; after?: number; @@ -89,6 +92,7 @@ export type SearchQueryParams = { cameras?: string[]; labels?: string[]; sub_labels?: string[]; + identifier?: string[]; zones?: string[]; before?: string; after?: string; diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index adbc96413..7f1b1e4a1 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -121,6 +121,7 @@ export default function SearchView({ }, [config, searchFilter]); const { data: allSubLabels } = useSWR("sub_labels"); + const { data: allIdentifiers } = useSWR("identifiers"); const allZones = useMemo(() => { if (!config) { @@ -160,12 +161,13 @@ export default function SearchView({ max_score: ["100"], min_speed: ["1"], max_speed: ["150"], + identifier: allIdentifiers, has_clip: ["yes", "no"], has_snapshot: ["yes", "no"], ...(config?.plus?.enabled && searchFilter?.has_snapshot && { is_submitted: ["yes", "no"] }), }), - [config, allLabels, allZones, allSubLabels, searchFilter], + [config, allLabels, allZones, allSubLabels, allIdentifiers, searchFilter], ); // remove duplicate event ids From b7333557a15053df3372f0a6793a95b4753f8aa9 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 12 Mar 2025 15:54:28 -0600 Subject: [PATCH 58/82] Enable audio by default (#17125) * Remove mp4 query, allowing go2rtc to send any audio * Add audio transcoding by default --- .../rootfs/usr/local/go2rtc/create_config.py | 25 ++++++------------- frigate/config/camera/ffmpeg.py | 2 +- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 30b78e1e1..5d8e80f9d 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -80,24 +80,15 @@ if go2rtc_config["webrtc"].get("candidates") is None: go2rtc_config["webrtc"]["candidates"] = default_candidates -# sets default RTSP response to be equivalent to ?video=h264,h265&audio=aac -# this means user does not need to specify audio codec when using restream -# as source for frigate and the integration supports HLS playback -if go2rtc_config.get("rtsp") is None: - go2rtc_config["rtsp"] = {"default_query": "mp4"} -else: - if go2rtc_config["rtsp"].get("default_query") is None: - go2rtc_config["rtsp"]["default_query"] = "mp4" +if go2rtc_config["rtsp"].get("username") is not None: + go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format( + **FRIGATE_ENV_VARS + ) - if go2rtc_config["rtsp"].get("username") is not None: - go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format( - **FRIGATE_ENV_VARS - ) - - if go2rtc_config["rtsp"].get("password") is not None: - go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format( - **FRIGATE_ENV_VARS - ) +if go2rtc_config["rtsp"].get("password") is not None: + go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format( + **FRIGATE_ENV_VARS + ) # ensure ffmpeg path is set correctly path = config.get("ffmpeg", {}).get("path", "default") diff --git a/frigate/config/camera/ffmpeg.py b/frigate/config/camera/ffmpeg.py index 0b1ec2331..04bbfac7b 100644 --- a/frigate/config/camera/ffmpeg.py +++ b/frigate/config/camera/ffmpeg.py @@ -21,7 +21,7 @@ __all__ = [ FFMPEG_GLOBAL_ARGS_DEFAULT = ["-hide_banner", "-loglevel", "warning", "-threads", "2"] FFMPEG_INPUT_ARGS_DEFAULT = "preset-rtsp-generic" -RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT = "preset-record-generic" +RECORD_FFMPEG_OUTPUT_ARGS_DEFAULT = "preset-record-generic-audio-aac" DETECT_FFMPEG_OUTPUT_ARGS_DEFAULT = [ "-threads", "2", From 124cc4c9cce02c22d87361f56a4ce4566c640c64 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 12 Mar 2025 18:45:16 -0500 Subject: [PATCH 59/82] Rename identifier field (#17128) * backend rename * frontend * docs * fix api path --- .../license_plate_recognition.md | 8 +- docs/docs/integrations/mqtt.md | 8 +- frigate/api/app.py | 38 +++--- .../api/defs/query/events_query_parameters.py | 4 +- frigate/api/event.py | 110 ++++++++++------- frigate/camera/state.py | 7 +- frigate/comms/event_metadata_updater.py | 2 +- .../common/license_plate/mixin.py | 3 +- frigate/events/maintainer.py | 15 ++- frigate/track/object_processing.py | 27 +++-- frigate/track/tracked_object.py | 10 +- .../overlay/detail/SearchDetailDialog.tsx | 22 ++-- .../overlay/dialog/SearchFilterDialog.tsx | 113 ++++++++++-------- web/src/pages/Explore.tsx | 6 +- web/src/types/search.ts | 8 +- web/src/views/search/SearchView.tsx | 15 ++- 16 files changed, 238 insertions(+), 158 deletions(-) diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index f8b9030ff..776f30cf9 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -3,16 +3,16 @@ id: license_plate_recognition title: License Plate Recognition (LPR) --- -Frigate can recognize license plates on vehicles and automatically add the detected characters to the `identifier` 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. +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. LPR works best when the license plate is clearly visible to the camera. For moving vehicles, Frigate continuously refines the recognition process, keeping the most confident result. However, LPR does not run on stationary vehicles. When a plate is recognized, the recognized name is: -- Added to the `car` tracked object as a `sub_label` (if known) or the `identifier` field (if unknown) +- Added to the `car` tracked object as a `sub_label` (if known) or the `recognized_license_plate` field (if unknown) - Viewable in the Review Item Details pane in Review and the Tracked Object Details pane in Explore. - Filterable through the More Filters menu in Explore. -- Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `identifier` (unknown) for the tracked object. +- Published via the `frigate/events` MQTT topic as a `sub_label` (known) or `recognized_license_plate` (unknown) for the tracked object. ## Model Requirements @@ -71,7 +71,7 @@ Fine-tune the LPR feature using these optional parameters: - **`known_plates`**: List of strings or regular expressions that assign custom a `sub_label` to `car` objects when a recognized plate matches a known value. - These labels appear in the UI, filters, and notifications. - - Unknown plates are still saved but are added to the `identifier` field rather than the `sub_label`. + - Unknown plates are still saved but are added to the `recognized_license_plate` field rather than the `sub_label`. - **`match_distance`**: Allows for minor variations (missing/incorrect characters) when matching a detected plate to a known plate. - For example, setting `match_distance: 1` allows a plate `ABCDE` to match `ABCBE` or `ABCD`. - This parameter will _not_ operate on known plates that are defined as regular expressions. You should define the full string of your plate in `known_plates` in order to use `match_distance`. diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index bcdbe7046..abbc12974 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -55,8 +55,8 @@ Message published for each changed tracked object. The first message is publishe "current_attributes": [], // detailed data about the current attributes in this frame "current_estimated_speed": 0.71, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled "velocity_angle": 180, // direction of travel relative to the frame for objects moving through zones with speed estimation enabled - "identifier": "ABC12345", // an identifier for this object - in this case, an unrecognized license plate - "identifier_score": 0.933451 + "recognized_license_plate": "ABC12345", // a recognized license plate for car objects + "recognized_license_plate_score": 0.933451 }, "after": { "id": "1607123955.475377-mxklsc", @@ -96,8 +96,8 @@ Message published for each changed tracked object. The first message is publishe ], "current_estimated_speed": 0.77, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled "velocity_angle": 180, // direction of travel relative to the frame for objects moving through zones with speed estimation enabled - "identifier": "ABC12345", // an identifier for this object - in this case, an unrecognized license plate - "identifier_score": 0.933451 + "recognized_license_plate": "ABC12345", // a recognized license plate for car objects + "recognized_license_plate_score": 0.933451 } } ``` diff --git a/frigate/api/app.py b/frigate/api/app.py index d9a57a3c1..05013ed12 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -619,37 +619,39 @@ def get_sub_labels(split_joined: Optional[int] = None): return JSONResponse(content=sub_labels) -@router.get("/identifiers") -def get_identifiers(split_joined: Optional[int] = None): +@router.get("/recognized_license_plates") +def get_recognized_license_plates(split_joined: Optional[int] = None): try: events = Event.select(Event.data).distinct() except Exception: return JSONResponse( - content=({"success": False, "message": "Failed to get identifiers"}), + content=( + {"success": False, "message": "Failed to get recognized license plates"} + ), status_code=404, ) - identifiers = [] + recognized_license_plates = [] for e in events: - if e.data is not None and "identifier" in e.data: - identifiers.append(e.data["identifier"]) + if e.data is not None and "recognized_license_plate" in e.data: + recognized_license_plates.append(e.data["recognized_license_plate"]) - while None in identifiers: - identifiers.remove(None) + while None in recognized_license_plates: + recognized_license_plates.remove(None) if split_joined: - original_identifiers = identifiers.copy() - for identifier in original_identifiers: - if identifier and "," in identifier: - identifiers.remove(identifier) - parts = identifier.split(",") + original_recognized_license_plates = recognized_license_plates.copy() + for recognized_license_plate in original_recognized_license_plates: + if recognized_license_plate and "," in recognized_license_plate: + recognized_license_plates.remove(recognized_license_plate) + parts = recognized_license_plate.split(",") for part in parts: - if part.strip() not in identifiers: - identifiers.append(part.strip()) + if part.strip() not in recognized_license_plates: + recognized_license_plates.append(part.strip()) - identifiers = list(set(identifiers)) - identifiers.sort() - return JSONResponse(content=identifiers) + recognized_license_plates = list(set(recognized_license_plates)) + recognized_license_plates.sort() + return JSONResponse(content=recognized_license_plates) @router.get("/timeline") diff --git a/frigate/api/defs/query/events_query_parameters.py b/frigate/api/defs/query/events_query_parameters.py index 9f73d8583..d707ba8cc 100644 --- a/frigate/api/defs/query/events_query_parameters.py +++ b/frigate/api/defs/query/events_query_parameters.py @@ -27,7 +27,7 @@ class EventsQueryParams(BaseModel): max_score: Optional[float] = None min_speed: Optional[float] = None max_speed: Optional[float] = None - identifier: Optional[str] = "all" + recognized_license_plate: Optional[str] = "all" is_submitted: Optional[int] = None min_length: Optional[float] = None max_length: Optional[float] = None @@ -56,7 +56,7 @@ class EventsSearchQueryParams(BaseModel): max_score: Optional[float] = None min_speed: Optional[float] = None max_speed: Optional[float] = None - identifier: Optional[str] = "all" + recognized_license_plate: Optional[str] = "all" sort: Optional[str] = None diff --git a/frigate/api/event.py b/frigate/api/event.py index e9cf2fea4..88a865318 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -101,7 +101,7 @@ def events(params: EventsQueryParams = Depends()): min_length = params.min_length max_length = params.max_length event_id = params.event_id - identifier = params.identifier + recognized_license_plate = params.recognized_license_plate sort = params.sort @@ -159,31 +159,44 @@ def events(params: EventsQueryParams = Depends()): sub_label_clause = reduce(operator.or_, sub_label_clauses) clauses.append((sub_label_clause)) - if identifier != "all": - # use matching so joined identifiers are included - # for example an identifier 'ABC123' would get events - # with identifiers 'ABC123' and 'ABC123, XYZ789' - identifier_clauses = [] - filtered_identifiers = identifier.split(",") + if recognized_license_plate != "all": + # use matching so joined recognized_license_plates are included + # for example a recognized license plate 'ABC123' would get events + # with recognized license plates 'ABC123' and 'ABC123, XYZ789' + recognized_license_plate_clauses = [] + filtered_recognized_license_plates = recognized_license_plate.split(",") - if "None" in filtered_identifiers: - filtered_identifiers.remove("None") - identifier_clauses.append((Event.data["identifier"].is_null())) + if "None" in filtered_recognized_license_plates: + filtered_recognized_license_plates.remove("None") + recognized_license_plate_clauses.append( + (Event.data["recognized_license_plate"].is_null()) + ) - for identifier in filtered_identifiers: + for recognized_license_plate in filtered_recognized_license_plates: # Exact matching plus list inclusion - identifier_clauses.append( - (Event.data["identifier"].cast("text") == identifier) + recognized_license_plate_clauses.append( + ( + Event.data["recognized_license_plate"].cast("text") + == recognized_license_plate + ) ) - identifier_clauses.append( - (Event.data["identifier"].cast("text") % f"*{identifier},*") + recognized_license_plate_clauses.append( + ( + Event.data["recognized_license_plate"].cast("text") + % f"*{recognized_license_plate},*" + ) ) - identifier_clauses.append( - (Event.data["identifier"].cast("text") % f"*, {identifier}*") + recognized_license_plate_clauses.append( + ( + Event.data["recognized_license_plate"].cast("text") + % f"*, {recognized_license_plate}*" + ) ) - identifier_clause = reduce(operator.or_, identifier_clauses) - clauses.append((identifier_clause)) + recognized_license_plate_clause = reduce( + operator.or_, recognized_license_plate_clauses + ) + clauses.append((recognized_license_plate_clause)) if zones != "all": # use matching so events with multiple zones @@ -367,8 +380,8 @@ def events_explore(limit: int = 10): "average_estimated_speed", "velocity_angle", "path_data", - "identifier", - "identifier_score", + "recognized_license_plate", + "recognized_license_plate_score", ] }, "event_count": label_counts[event.label], @@ -426,7 +439,7 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) has_clip = params.has_clip has_snapshot = params.has_snapshot is_submitted = params.is_submitted - identifier = params.identifier + recognized_license_plate = params.recognized_license_plate # for similarity search event_id = params.event_id @@ -496,31 +509,44 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) event_filters.append((reduce(operator.or_, zone_clauses))) - if identifier != "all": - # use matching so joined identifiers are included - # for example an identifier 'ABC123' would get events - # with identifiers 'ABC123' and 'ABC123, XYZ789' - identifier_clauses = [] - filtered_identifiers = identifier.split(",") + if recognized_license_plate != "all": + # use matching so joined recognized_license_plates are included + # for example an recognized_license_plate 'ABC123' would get events + # with recognized_license_plates 'ABC123' and 'ABC123, XYZ789' + recognized_license_plate_clauses = [] + filtered_recognized_license_plates = recognized_license_plate.split(",") - if "None" in filtered_identifiers: - filtered_identifiers.remove("None") - identifier_clauses.append((Event.data["identifier"].is_null())) + if "None" in filtered_recognized_license_plates: + filtered_recognized_license_plates.remove("None") + recognized_license_plate_clauses.append( + (Event.data["recognized_license_plate"].is_null()) + ) - for identifier in filtered_identifiers: + for recognized_license_plate in filtered_recognized_license_plates: # Exact matching plus list inclusion - identifier_clauses.append( - (Event.data["identifier"].cast("text") == identifier) + recognized_license_plate_clauses.append( + ( + Event.data["recognized_license_plate"].cast("text") + == recognized_license_plate + ) ) - identifier_clauses.append( - (Event.data["identifier"].cast("text") % f"*{identifier},*") + recognized_license_plate_clauses.append( + ( + Event.data["recognized_license_plate"].cast("text") + % f"*{recognized_license_plate},*" + ) ) - identifier_clauses.append( - (Event.data["identifier"].cast("text") % f"*, {identifier}*") + recognized_license_plate_clauses.append( + ( + Event.data["recognized_license_plate"].cast("text") + % f"*, {recognized_license_plate}*" + ) ) - identifier_clause = reduce(operator.or_, identifier_clauses) - event_filters.append((identifier_clause)) + recognized_license_plate_clause = reduce( + operator.or_, recognized_license_plate_clauses + ) + event_filters.append((recognized_license_plate_clause)) if after: event_filters.append((Event.start_time > after)) @@ -683,8 +709,8 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends()) "average_estimated_speed", "velocity_angle", "path_data", - "identifier", - "identifier_score", + "recognized_license_plate", + "recognized_license_plate_score", ] } diff --git a/frigate/camera/state.py b/frigate/camera/state.py index 2ffa54798..0e02c6c14 100644 --- a/frigate/camera/state.py +++ b/frigate/camera/state.py @@ -141,8 +141,11 @@ class CameraState: if ( obj.get("sub_label") and is_label_printable(obj["sub_label"][0]) ) - else obj.get("identifier", [None])[0] - if (obj.get("identifier") and obj["identifier"][0]) + else obj.get("recognized_license_plate", [None])[0] + if ( + obj.get("recognized_license_plate") + and obj["recognized_license_plate"][0] + ) else obj["label"] ) draw_box_with_label( diff --git a/frigate/comms/event_metadata_updater.py b/frigate/comms/event_metadata_updater.py index c5881e686..c702208bc 100644 --- a/frigate/comms/event_metadata_updater.py +++ b/frigate/comms/event_metadata_updater.py @@ -14,7 +14,7 @@ class EventMetadataTypeEnum(str, Enum): manual_event_end = "manual_event_end" regenerate_description = "regenerate_description" sub_label = "sub_label" - identifier = "identifier" + recognized_license_plate = "recognized_license_plate" class EventMetadataPublisher(Publisher): diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index 37530b205..751a674f5 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -1064,7 +1064,8 @@ class LicensePlateProcessingMixin: ) self.sub_label_publisher.publish( - EventMetadataTypeEnum.identifier, (id, top_plate, avg_confidence) + EventMetadataTypeEnum.recognized_license_plate, + (id, top_plate, avg_confidence), ) self.detected_license_plates[id] = { diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index 947763f40..7788c83e9 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -27,7 +27,8 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool: or prev_event["average_estimated_speed"] != current_event["average_estimated_speed"] or prev_event["velocity_angle"] != current_event["velocity_angle"] - or prev_event["identifier"] != current_event["identifier"] + or prev_event["recognized_license_plate"] + != current_event["recognized_license_plate"] or prev_event["path_data"] != current_event["path_data"] ): return True @@ -227,10 +228,14 @@ class EventProcessor(threading.Thread): event[Event.sub_label] = event_data["sub_label"][0] event[Event.data]["sub_label_score"] = event_data["sub_label"][1] - # only overwrite the identifier in the database if it's set - if event_data.get("identifier") is not None: - event[Event.data]["identifier"] = event_data["identifier"][0] - event[Event.data]["identifier_score"] = event_data["identifier"][1] + # only overwrite the recognized_license_plate in the database if it's set + if event_data.get("recognized_license_plate") is not None: + event[Event.data]["recognized_license_plate"] = event_data[ + "recognized_license_plate" + ][0] + event[Event.data]["recognized_license_plate_score"] = event_data[ + "recognized_license_plate" + ][1] ( Event.insert(event) diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index 56dd59110..b18ad97fa 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -346,10 +346,10 @@ class TrackedObjectProcessor(threading.Thread): return True - def set_identifier( - self, event_id: str, identifier: str | None, score: float | None + def set_recognized_license_plate( + self, event_id: str, recognized_license_plate: str | None, score: float | None ) -> None: - """Update identifier for given event id.""" + """Update recognized license plate for given event id.""" tracked_obj: TrackedObject = None for state in self.camera_states.values(): @@ -367,15 +367,18 @@ class TrackedObjectProcessor(threading.Thread): return if tracked_obj: - tracked_obj.obj_data["identifier"] = (identifier, score) + tracked_obj.obj_data["recognized_license_plate"] = ( + recognized_license_plate, + score, + ) if event: data = event.data - data["identifier"] = identifier - if identifier is None: - data["identifier_score"] = None + data["recognized_license_plate"] = recognized_license_plate + if recognized_license_plate is None: + data["recognized_license_plate_score"] = None elif score is not None: - data["identifier_score"] = score + data["recognized_license_plate_score"] = score event.data = data event.save() @@ -542,9 +545,11 @@ class TrackedObjectProcessor(threading.Thread): if topic.endswith(EventMetadataTypeEnum.sub_label.value): (event_id, sub_label, score) = payload self.set_sub_label(event_id, sub_label, score) - if topic.endswith(EventMetadataTypeEnum.identifier.value): - (event_id, identifier, score) = payload - self.set_identifier(event_id, identifier, score) + if topic.endswith(EventMetadataTypeEnum.recognized_license_plate.value): + (event_id, recognized_license_plate, score) = payload + self.set_recognized_license_plate( + event_id, recognized_license_plate, score + ) elif topic.endswith(EventMetadataTypeEnum.manual_event_create.value): self.create_manual_event(payload) elif topic.endswith(EventMetadataTypeEnum.manual_event_end.value): diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index 9c19595b5..7a4829c2a 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -153,8 +153,12 @@ class TrackedObject: "current_estimated_speed": self.current_estimated_speed, "velocity_angle": self.velocity_angle, "path_data": self.path_data, - "identifier": obj_data.get("identifier"), - "identifier_score": obj_data.get("identifier_score"), + "recognized_license_plate": obj_data.get( + "recognized_license_plate" + ), + "recognized_license_plate_score": obj_data.get( + "recognized_license_plate_score" + ), } thumb_update = True @@ -367,7 +371,7 @@ class TrackedObject: "average_estimated_speed": self.average_estimated_speed, "velocity_angle": self.velocity_angle, "path_data": self.path_data, - "identifier": self.obj_data.get("identifier"), + "recognized_license_plate": self.obj_data.get("recognized_license_plate"), } return event diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 7f69f2ca2..ed472c742 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -333,13 +333,18 @@ function ObjectDetailsTab({ } }, [search]); - const identifierScore = useMemo(() => { + const recognizedLicensePlateScore = useMemo(() => { if (!search) { return undefined; } - if (search.data.identifier && search.data?.identifier_score) { - return Math.round((search.data?.identifier_score ?? 0) * 100); + if ( + search.data.recognized_license_plate && + search.data?.recognized_license_plate_score + ) { + return Math.round( + (search.data?.recognized_license_plate_score ?? 0) * 100, + ); } else { return undefined; } @@ -550,13 +555,16 @@ function ObjectDetailsTab({
- {search?.data.identifier && ( + {search?.data.recognized_license_plate && (
-
Identifier
+
+ Recognized License Plate +
- {search.data.identifier}{" "} - {identifierScore && ` (${identifierScore}%)`} + {search.data.recognized_license_plate}{" "} + {recognizedLicensePlateScore && + ` (${recognizedLicensePlateScore}%)`}
diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx index 59b30f82f..2768f8859 100644 --- a/web/src/components/overlay/dialog/SearchFilterDialog.tsx +++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx @@ -86,7 +86,7 @@ export default function SearchFilterDialog({ (currentFilter.max_speed ?? 150) < 150 || (currentFilter.zones?.length ?? 0) > 0 || (currentFilter.sub_labels?.length ?? 0) > 0 || - (currentFilter.identifier?.length ?? 0) > 0), + (currentFilter.recognized_license_plate?.length ?? 0) > 0), [currentFilter], ); @@ -128,10 +128,13 @@ export default function SearchFilterDialog({ setCurrentFilter({ ...currentFilter, sub_labels: newSubLabels }) } /> - - setCurrentFilter({ ...currentFilter, identifier: identifiers }) + + setCurrentFilter({ + ...currentFilter, + recognized_license_plate: plate, + }) } /> @@ -847,97 +850,109 @@ export function SnapshotClipFilterContent({ ); } -type IdentifierFilterContentProps = { - identifiers: string[] | undefined; - setIdentifiers: (identifiers: string[] | undefined) => void; +type RecognizedLicensePlatesFilterContentProps = { + recognizedLicensePlates: string[] | undefined; + setRecognizedLicensePlates: ( + recognizedLicensePlates: string[] | undefined, + ) => void; }; -export function IdentifierFilterContent({ - identifiers, - setIdentifiers, -}: IdentifierFilterContentProps) { - const { data: allIdentifiers, error } = useSWR("identifiers", { - revalidateOnFocus: false, - }); - - const [selectedIdentifiers, setSelectedIdentifiers] = useState( - identifiers || [], +export function RecognizedLicensePlatesFilterContent({ + recognizedLicensePlates, + setRecognizedLicensePlates, +}: RecognizedLicensePlatesFilterContentProps) { + const { data: allRecognizedLicensePlates, error } = useSWR( + "recognized_license_plates", + { + revalidateOnFocus: false, + }, ); + + const [selectedRecognizedLicensePlates, setSelectedRecognizedLicensePlates] = + useState(recognizedLicensePlates || []); const [inputValue, setInputValue] = useState(""); useEffect(() => { - if (identifiers) { - setSelectedIdentifiers(identifiers); + if (recognizedLicensePlates) { + setSelectedRecognizedLicensePlates(recognizedLicensePlates); } else { - setSelectedIdentifiers([]); + setSelectedRecognizedLicensePlates([]); } - }, [identifiers]); + }, [recognizedLicensePlates]); - const handleSelect = (identifier: string) => { - const newSelected = selectedIdentifiers.includes(identifier) - ? selectedIdentifiers.filter((id) => id !== identifier) // Deselect - : [...selectedIdentifiers, identifier]; // Select + const handleSelect = (recognizedLicensePlate: string) => { + const newSelected = selectedRecognizedLicensePlates.includes( + recognizedLicensePlate, + ) + ? selectedRecognizedLicensePlates.filter( + (id) => id !== recognizedLicensePlate, + ) // Deselect + : [...selectedRecognizedLicensePlates, recognizedLicensePlate]; // Select - setSelectedIdentifiers(newSelected); + setSelectedRecognizedLicensePlates(newSelected); if (newSelected.length === 0) { - setIdentifiers(undefined); // Clear filter if no identifiers selected + setRecognizedLicensePlates(undefined); // Clear filter if no plates selected } else { - setIdentifiers(newSelected); + setRecognizedLicensePlates(newSelected); } }; - if (!allIdentifiers || allIdentifiers.length === 0) { + if (!allRecognizedLicensePlates || allRecognizedLicensePlates.length === 0) { return null; } - const filteredIdentifiers = - allIdentifiers?.filter((id) => + const filteredRecognizedLicensePlates = + allRecognizedLicensePlates?.filter((id) => id.toLowerCase().includes(inputValue.toLowerCase()), ) || []; return (
-
Identifiers
+
Recognized License Plates
{error ? ( -

Failed to load identifiers

- ) : !allIdentifiers ? ( -

Loading identifiers...

+

+ Failed to load recognized license plates. +

+ ) : !allRecognizedLicensePlates ? ( +

+ Loading recognized license plates... +

) : ( <> - {filteredIdentifiers.length === 0 && inputValue && ( - No identifiers found. + {filteredRecognizedLicensePlates.length === 0 && inputValue && ( + No license plates found. )} - {filteredIdentifiers.map((identifier) => ( + {filteredRecognizedLicensePlates.map((plate) => ( handleSelect(identifier)} + key={plate} + value={plate} + onSelect={() => handleSelect(plate)} className="cursor-pointer" > - {identifier} + {plate} ))} - {selectedIdentifiers.length > 0 && ( + {selectedRecognizedLicensePlates.length > 0 && (
- {selectedIdentifiers.map((id) => ( + {selectedRecognizedLicensePlates.map((id) => ( )}

- Select one or more identifiers from the list. + Select one or more plates from the list.

); diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index cf24ff70d..31fe81d04 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -105,7 +105,8 @@ export default function Explore() { cameras: searchSearchParams["cameras"], labels: searchSearchParams["labels"], sub_labels: searchSearchParams["sub_labels"], - identifier: searchSearchParams["identifier"], + recognized_license_plate: + searchSearchParams["recognized_license_plate"], zones: searchSearchParams["zones"], before: searchSearchParams["before"], after: searchSearchParams["after"], @@ -141,7 +142,8 @@ export default function Explore() { cameras: searchSearchParams["cameras"], labels: searchSearchParams["labels"], sub_labels: searchSearchParams["sub_labels"], - identifier: searchSearchParams["identifier"], + recognized_license_plate: + searchSearchParams["recognized_license_plate"], zones: searchSearchParams["zones"], before: searchSearchParams["before"], after: searchSearchParams["after"], diff --git a/web/src/types/search.ts b/web/src/types/search.ts index 90bcd54d7..5dca11973 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -58,8 +58,8 @@ export type SearchResult = { average_estimated_speed: number; velocity_angle: number; path_data: [number[], number][]; - identifier?: string; - identifier_score?: number; + recognized_license_plate?: string; + recognized_license_plate_score?: number; }; }; @@ -68,7 +68,7 @@ export type SearchFilter = { cameras?: string[]; labels?: string[]; sub_labels?: string[]; - identifier?: string[]; + recognized_license_plate?: string[]; zones?: string[]; before?: number; after?: number; @@ -92,7 +92,7 @@ export type SearchQueryParams = { cameras?: string[]; labels?: string[]; sub_labels?: string[]; - identifier?: string[]; + recognized_license_plate?: string[]; zones?: string[]; before?: string; after?: string; diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 7f1b1e4a1..a8d241c00 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -121,7 +121,9 @@ export default function SearchView({ }, [config, searchFilter]); const { data: allSubLabels } = useSWR("sub_labels"); - const { data: allIdentifiers } = useSWR("identifiers"); + const { data: allRecognizedLicensePlates } = useSWR( + "recognized_license_plates", + ); const allZones = useMemo(() => { if (!config) { @@ -161,13 +163,20 @@ export default function SearchView({ max_score: ["100"], min_speed: ["1"], max_speed: ["150"], - identifier: allIdentifiers, + recognized_license_plate: allRecognizedLicensePlates, has_clip: ["yes", "no"], has_snapshot: ["yes", "no"], ...(config?.plus?.enabled && searchFilter?.has_snapshot && { is_submitted: ["yes", "no"] }), }), - [config, allLabels, allZones, allSubLabels, allIdentifiers, searchFilter], + [ + config, + allLabels, + allZones, + allSubLabels, + allRecognizedLicensePlates, + searchFilter, + ], ); // remove duplicate event ids From b8f2d8fb0c94ab4ebfac51858a19f989972c0011 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 12 Mar 2025 20:23:06 -0600 Subject: [PATCH 60/82] Fix rtsp config access (#17129) --- docker/main/rootfs/usr/local/go2rtc/create_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index 5d8e80f9d..d7c21c7f7 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -80,12 +80,12 @@ if go2rtc_config["webrtc"].get("candidates") is None: go2rtc_config["webrtc"]["candidates"] = default_candidates -if go2rtc_config["rtsp"].get("username") is not None: +if go2rtc_config.get("rtsp", {}).get("username") is not None: go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format( **FRIGATE_ENV_VARS ) -if go2rtc_config["rtsp"].get("password") is not None: +if go2rtc_config.get("rtsp", {}).get("password") is not None: go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format( **FRIGATE_ENV_VARS ) From c93b82d6e18e0164251016845bec0199ddb26d7f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 13 Mar 2025 09:57:12 -0500 Subject: [PATCH 61/82] Ensure restart dialog isn't shown if config isn't saved successfully first (#17132) --- web/src/pages/ConfigEditor.tsx | 60 ++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/web/src/pages/ConfigEditor.tsx b/web/src/pages/ConfigEditor.tsx index a8ca0eda3..df859beb5 100644 --- a/web/src/pages/ConfigEditor.tsx +++ b/web/src/pages/ConfigEditor.tsx @@ -6,7 +6,7 @@ import { useApiHost } from "@/api"; import Heading from "@/components/ui/heading"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Button } from "@/components/ui/button"; -import axios from "axios"; +import axios, { AxiosError } from "axios"; import copy from "copy-to-clipboard"; import { useTheme } from "@/context/theme-provider"; import { Toaster } from "@/components/ui/sonner"; @@ -14,9 +14,15 @@ import { toast } from "sonner"; import { LuCopy, LuSave } from "react-icons/lu"; import { MdOutlineRestartAlt } from "react-icons/md"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; +import { useRestart } from "@/api/ws"; type SaveOptions = "saveonly" | "restart"; +type ApiErrorResponse = { + message?: string; + detail?: string; +}; + function ConfigEditor() { const apiHost = useApiHost(); @@ -35,37 +41,40 @@ function ConfigEditor() { const schemaConfiguredRef = useRef(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const { send: sendRestart } = useRestart(); const onHandleSaveConfig = useCallback( - async (save_option: SaveOptions) => { + async (save_option: SaveOptions): Promise => { if (!editorRef.current) { return; } - axios - .post( + try { + const response = await axios.post( `config/save?save_option=${save_option}`, editorRef.current.getValue(), { headers: { "Content-Type": "text/plain" }, }, - ) - .then((response) => { - if (response.status === 200) { - setError(""); - toast.success(response.data.message, { position: "top-center" }); - } - }) - .catch((error) => { - toast.error("Error saving config", { position: "top-center" }); + ); - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; + if (response.status === 200) { + setError(""); + setHasChanges(false); + toast.success(response.data.message, { position: "top-center" }); + } + } catch (error) { + toast.error("Error saving config", { position: "top-center" }); - setError(errorMessage); - }); + const axiosError = error as AxiosError; + const errorMessage = + axiosError.response?.data?.message || + axiosError.response?.data?.detail || + "Unknown error"; + + setError(errorMessage); + throw new Error(errorMessage); + } }, [editorRef], ); @@ -79,6 +88,15 @@ function ConfigEditor() { toast.success("Config copied to clipboard.", { position: "top-center" }); }, [editorRef]); + const handleSaveAndRestart = useCallback(async () => { + try { + await onHandleSaveConfig("saveonly"); + setRestartDialogOpen(true); + } catch (error) { + // If save fails, error is already set in onHandleSaveConfig, no dialog opens + } + }, [onHandleSaveConfig]); + useEffect(() => { if (!config) { return; @@ -206,7 +224,7 @@ function ConfigEditor() { size="sm" className="flex items-center gap-2" aria-label="Save and restart" - onClick={() => setRestartDialogOpen(true)} + onClick={handleSaveAndRestart} >
@@ -238,7 +256,7 @@ function ConfigEditor() { setRestartDialogOpen(false)} - onRestart={() => onHandleSaveConfig("restart")} + onRestart={() => sendRestart("restart")} />
); From 8d05e7c5b90f38818da570e319578b2e3d7021b6 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 13 Mar 2025 11:22:14 -0600 Subject: [PATCH 62/82] Make detection threhsold configurable (#17136) --- frigate/config/classification.py | 8 +++++++- frigate/data_processing/real_time/face.py | 6 +++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/frigate/config/classification.py b/frigate/config/classification.py index 07d986d7d..30cd12b7c 100644 --- a/frigate/config/classification.py +++ b/frigate/config/classification.py @@ -55,7 +55,13 @@ class FaceRecognitionConfig(FrigateBaseModel): gt=0.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, title="Minimum face distance score required to be considered a match.", gt=0.0, diff --git a/frigate/data_processing/real_time/face.py b/frigate/data_processing/real_time/face.py index c88aae027..7d97f8586 100644 --- a/frigate/data_processing/real_time/face.py +++ b/frigate/data_processing/real_time/face.py @@ -88,7 +88,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): os.path.join(MODEL_CACHE_DIR, "facedet/facedet.onnx"), config="", input_size=(320, 320), - score_threshold=0.8, + score_threshold=self.face_config.detection_threshold, nms_threshold=0.3, ) self.landmark_detector = cv2.face.createFacemarkLBF() @@ -367,9 +367,9 @@ class FaceRealTimeProcessor(RealTimeProcessorApi): os.makedirs(folder, exist_ok=True) cv2.imwrite(file, face_frame) - if score < self.config.face_recognition.threshold: + if score < self.config.face_recognition.recognition_threshold: 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) return From 4f6d70ded0e28a28a50fcb33a5813ec0e0d72569 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 13 Mar 2025 15:20:09 -0500 Subject: [PATCH 63/82] Review items per user (#17137) * model * migration * api changes * delete in cleanup * fix tests --- frigate/api/review.py | 192 +++++++++++++++----- frigate/models.py | 11 +- frigate/record/cleanup.py | 6 +- frigate/test/http_api/base_http_test.py | 4 +- frigate/test/http_api/test_http_review.py | 112 +++++++----- migrations/030_create_user_review_status.py | 85 +++++++++ 6 files changed, 306 insertions(+), 104 deletions(-) create mode 100644 migrations/030_create_user_review_status.py diff --git a/frigate/api/review.py b/frigate/api/review.py index 4788356f3..b04c8353a 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -9,10 +9,10 @@ import pandas as pd from fastapi import APIRouter from fastapi.params import Depends 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 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 ( ReviewActivityMotionQueryParams, ReviewQueryParams, @@ -26,7 +26,7 @@ from frigate.api.defs.response.review_response import ( ReviewSummaryResponse, ) 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.util.builtin import get_tz_modifiers @@ -36,7 +36,15 @@ router = APIRouter(tags=[Tags.review]) @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 labels = params.labels zones = params.zones @@ -74,9 +82,7 @@ def review(params: ReviewQueryParams = Depends()): (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') | (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*') ) - - label_clause = reduce(operator.or_, label_clauses) - clauses.append((label_clause)) + clauses.append(reduce(operator.or_, label_clauses)) if zones != "all": # use matching so segments with multiple zones @@ -88,27 +94,52 @@ def review(params: ReviewQueryParams = Depends()): zone_clauses.append( (ReviewSegment.data["zones"].cast("text") % f'*"{zone}"*') ) - - zone_clause = reduce(operator.or_, zone_clauses) - clauses.append((zone_clause)) - - if reviewed == 0: - clauses.append((ReviewSegment.has_been_reviewed == False)) + clauses.append(reduce(operator.or_, zone_clauses)) if severity: clauses.append((ReviewSegment.severity == severity)) - review = ( - ReviewSegment.select() + # Join with UserReviewStatus to get per-user review status + 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)) - .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()) .limit(limit) .dicts() .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]) @@ -134,7 +165,15 @@ def review_ids(ids: str): @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) day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).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["audio"].cast("text") % f'*"{label}"*') ) - - label_clause = reduce(operator.or_, label_clauses) - clauses.append((label_clause)) - + clauses.append(reduce(operator.or_, label_clauses)) if zones != "all": # use matching so segments with multiple zones # still match on a search where any zone matches @@ -172,21 +208,20 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()): for zone in filtered_zones: 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)) - - last_24 = ( + last_24_query = ( ReviewSegment.select( fn.SUM( Case( None, [ ( - (ReviewSegment.severity == SeverityEnum.alert), - ReviewSegment.has_been_reviewed, + (ReviewSegment.severity == SeverityEnum.alert) + & (UserReviewStatus.has_been_reviewed == True), + 1, ) ], 0, @@ -197,8 +232,9 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()): None, [ ( - (ReviewSegment.severity == SeverityEnum.detection), - ReviewSegment.has_been_reviewed, + (ReviewSegment.severity == SeverityEnum.detection) + & (UserReviewStatus.has_been_reviewed == True), + 1, ) ], 0, @@ -229,6 +265,13 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()): ) ).alias("total_detection"), ) + .left_outer_join( + UserReviewStatus, + on=( + (ReviewSegment.id == UserReviewStatus.review_segment) + & (UserReviewStatus.user_id == user_id) + ), + ) .where(reduce(operator.and_, clauses)) .dicts() .get() @@ -248,14 +291,12 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()): for label in filtered_labels: label_clauses.append( - (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') + ReviewSegment.data["objects"].cast("text") % f'*"{label}"*' ) - - label_clause = reduce(operator.or_, label_clauses) - clauses.append((label_clause)) + clauses.append(reduce(operator.or_, label_clauses)) day_in_seconds = 60 * 60 * 24 - last_month = ( + last_month_query = ( ReviewSegment.select( fn.strftime( "%Y-%m-%d", @@ -271,8 +312,9 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()): None, [ ( - (ReviewSegment.severity == SeverityEnum.alert), - ReviewSegment.has_been_reviewed, + (ReviewSegment.severity == SeverityEnum.alert) + & (UserReviewStatus.has_been_reviewed == True), + 1, ) ], 0, @@ -283,8 +325,9 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()): None, [ ( - (ReviewSegment.severity == SeverityEnum.detection), - ReviewSegment.has_been_reviewed, + (ReviewSegment.severity == SeverityEnum.detection) + & (UserReviewStatus.has_been_reviewed == True), + 1, ) ], 0, @@ -315,28 +358,59 @@ def review_summary(params: ReviewSummaryQueryParams = Depends()): ) ).alias("total_detection"), ) + .left_outer_join( + UserReviewStatus, + on=( + (ReviewSegment.id == UserReviewStatus.review_segment) + & (UserReviewStatus.user_id == user_id) + ), + ) .where(reduce(operator.and_, clauses)) .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()) ) 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 return JSONResponse(content=data) @router.post("/reviews/viewed", response_model=GenericResponse) -def set_multiple_reviewed(body: ReviewModifyMultipleBody): - ReviewSegment.update(has_been_reviewed=True).where( - ReviewSegment.id << body.ids - ).execute() +async def set_multiple_reviewed( + body: ReviewModifyMultipleBody, + current_user: dict = Depends(get_current_user), +): + 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 isn’t 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( content=({"success": True, "message": "Reviewed multiple items"}), @@ -389,6 +463,9 @@ def delete_reviews(body: ReviewModifyMultipleBody): # delete recordings and review segments Recordings.delete().where(Recordings.id << recording_ids).execute() ReviewSegment.delete().where(ReviewSegment.id << list_of_ids).execute() + UserReviewStatus.delete().where( + UserReviewStatus.review_segment << list_of_ids + ).execute() return JSONResponse( 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) -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: review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == review_id) except DoesNotExist: @@ -513,8 +598,15 @@ def set_not_reviewed(review_id: str): status_code=404, ) - review.has_been_reviewed = False - review.save() + try: + 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( content=({"success": True, "message": f"Set Review {review_id} as not viewed"}), diff --git a/frigate/models.py b/frigate/models.py index 11b25b938..5aa0dc5b2 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -3,6 +3,7 @@ from peewee import ( CharField, DateTimeField, FloatField, + ForeignKeyField, IntegerField, Model, TextField, @@ -92,12 +93,20 @@ class ReviewSegment(Model): # type: ignore[misc] camera = CharField(index=True, max_length=20) start_time = DateTimeField() end_time = DateTimeField() - has_been_reviewed = BooleanField(default=False) severity = CharField(max_length=30) # alert, detection thumb_path = CharField(unique=True) 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] id = CharField(null=False, primary_key=True, max_length=30) camera = CharField(index=True, max_length=20) diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index e526b020d..c86c81859 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -12,7 +12,7 @@ from playhouse.sqlite_ext import SqliteExtDatabase from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum 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.util.builtin import clear_and_unlink, get_tomorrow_at_time @@ -90,6 +90,10 @@ class RecordingCleanup(threading.Thread): ReviewSegment.delete().where( ReviewSegment.id << deleted_reviews_list[i : i + max_deletes] ).execute() + UserReviewStatus.delete().where( + UserReviewStatus.review_segment + << deleted_reviews_list[i : i + max_deletes] + ).execute() def expire_existing_camera_recordings( self, expire_date: float, config: CameraConfig, reviews: ReviewSegment diff --git a/frigate/test/http_api/base_http_test.py b/frigate/test/http_api/base_http_test.py index 35cda7b79..3c4a7ccdc 100644 --- a/frigate/test/http_api/base_http_test.py +++ b/frigate/test/http_api/base_http_test.py @@ -157,16 +157,14 @@ class BaseTestHttp(unittest.TestCase): start_time: float = datetime.datetime.now().timestamp(), end_time: float = datetime.datetime.now().timestamp() + 20, severity: SeverityEnum = SeverityEnum.alert, - has_been_reviewed: bool = False, data: Json = {}, - ) -> Event: + ) -> ReviewSegment: """Inserts a review segment model with a given id.""" return ReviewSegment.insert( id=id, camera="front_door", start_time=start_time, end_time=end_time, - has_been_reviewed=has_been_reviewed, severity=severity, thumb_path=False, data=data, diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index ee7d96bc5..19c589a67 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -1,16 +1,29 @@ from datetime import datetime, timedelta 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.test.http_api.base_http_test import BaseTestHttp class TestHttpReview(BaseTestHttp): def setUp(self): - super().setUp([Event, Recordings, ReviewSegment]) + super().setUp([Event, Recordings, ReviewSegment, UserReviewStatus]) 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]): return list( @@ -24,6 +37,13 @@ class TestHttpReview(BaseTestHttp): 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 ######################################################## #################################################################################################################### @@ -43,11 +63,14 @@ class TestHttpReview(BaseTestHttp): now = datetime.now().timestamp() 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") assert response.status_code == 200 response_json = response.json() 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): now = datetime.now().timestamp() @@ -391,37 +414,27 @@ class TestHttpReview(BaseTestHttp): with TestClient(self.app) as client: five_days_ago_ts = five_days_ago.timestamp() for i in range(10): + id = f"123456_{i}.random_alert_not_reviewed" super().insert_mock_review_segment( - f"123456_{i}.random_alert_not_reviewed", - five_days_ago_ts, - five_days_ago_ts, - SeverityEnum.alert, - False, + id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.alert ) for i in range(10): + id = f"123456_{i}.random_alert_reviewed" super().insert_mock_review_segment( - f"123456_{i}.random_alert_reviewed", - five_days_ago_ts, - five_days_ago_ts, - SeverityEnum.alert, - True, + id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.alert ) + self._insert_user_review_status(id, reviewed=True) for i in range(10): + id = f"123456_{i}.random_detection_not_reviewed" super().insert_mock_review_segment( - f"123456_{i}.random_detection_not_reviewed", - five_days_ago_ts, - five_days_ago_ts, - SeverityEnum.detection, - False, + id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.detection ) for i in range(5): + id = f"123456_{i}.random_detection_reviewed" super().insert_mock_review_segment( - f"123456_{i}.random_detection_reviewed", - five_days_ago_ts, - five_days_ago_ts, - SeverityEnum.detection, - True, + id, five_days_ago_ts, five_days_ago_ts, SeverityEnum.detection ) + self._insert_user_review_status(id, reviewed=True) response = client.get("/review/summary") assert response.status_code == 200 response_json = response.json() @@ -447,6 +460,7 @@ class TestHttpReview(BaseTestHttp): #################################################################################################################### ################################### POST reviews/viewed Endpoint ################################################ #################################################################################################################### + def test_post_reviews_viewed_no_body(self): with TestClient(self.app) as client: super().insert_mock_review_segment("123456.random") @@ -473,12 +487,11 @@ class TestHttpReview(BaseTestHttp): assert response["success"] == True assert response["message"] == "Reviewed multiple items" # Verify that in DB the review segment was not changed - review_segment_in_db = ( - ReviewSegment.select(ReviewSegment.has_been_reviewed) - .where(ReviewSegment.id == id) - .get() - ) - assert review_segment_in_db.has_been_reviewed == False + with self.assertRaises(DoesNotExist): + UserReviewStatus.get( + UserReviewStatus.user_id == self.user_id, + UserReviewStatus.review_segment == "1", + ) def test_post_reviews_viewed(self): with TestClient(self.app) as client: @@ -487,16 +500,15 @@ class TestHttpReview(BaseTestHttp): body = {"ids": [id]} response = client.post("/reviews/viewed", json=body) assert response.status_code == 200 - response = response.json() - assert response["success"] == True - assert response["message"] == "Reviewed multiple items" - # Verify that in DB the review segment was changed - review_segment_in_db = ( - ReviewSegment.select(ReviewSegment.has_been_reviewed) - .where(ReviewSegment.id == id) - .get() + response_json = response.json() + assert response_json["success"] == True + assert response_json["message"] == "Reviewed multiple items" + # Verify UserReviewStatus was created + user_review = UserReviewStatus.get( + UserReviewStatus.user_id == self.user_id, + UserReviewStatus.review_segment == id, ) - assert review_segment_in_db.has_been_reviewed == True + assert user_review.has_been_reviewed == True #################################################################################################################### ################################### POST reviews/delete Endpoint ################################################ @@ -672,8 +684,7 @@ class TestHttpReview(BaseTestHttp): "camera": "front_door", "start_time": now + 1, "end_time": now + 2, - "has_been_reviewed": False, - "severity": SeverityEnum.alert, + "severity": "alert", "thumb_path": "False", "data": {"detections": {"event_id": event_id}}, }, @@ -708,8 +719,7 @@ class TestHttpReview(BaseTestHttp): "camera": "front_door", "start_time": now + 1, "end_time": now + 2, - "has_been_reviewed": False, - "severity": SeverityEnum.alert, + "severity": "alert", "thumb_path": "False", "data": {}, }, @@ -719,6 +729,7 @@ class TestHttpReview(BaseTestHttp): #################################################################################################################### ################################### DELETE /review/{review_id}/viewed Endpoint ################################## #################################################################################################################### + def test_delete_review_viewed_review_not_found(self): with TestClient(self.app) as client: review_id = "123456.random" @@ -735,11 +746,10 @@ class TestHttpReview(BaseTestHttp): with TestClient(self.app) as client: review_id = "123456.review.random" - super().insert_mock_review_segment( - review_id, now + 1, now + 2, has_been_reviewed=True - ) - review_before = ReviewSegment.get(ReviewSegment.id == review_id) - assert review_before.has_been_reviewed == True + super().insert_mock_review_segment(review_id, now + 1, now + 2) + self._insert_user_review_status(review_id, reviewed=True) + # Verify it’s reviewed before + response = client.get(f"/review/{review_id}") response = client.delete(f"/review/{review_id}/viewed") assert response.status_code == 200 @@ -749,5 +759,9 @@ class TestHttpReview(BaseTestHttp): response_json, ) - review_after = ReviewSegment.get(ReviewSegment.id == review_id) - assert review_after.has_been_reviewed == False + # Verify it’s unreviewed after + with self.assertRaises(DoesNotExist): + UserReviewStatus.get( + UserReviewStatus.user_id == self.user_id, + UserReviewStatus.review_segment == review_id, + ) diff --git a/migrations/030_create_user_review_status.py b/migrations/030_create_user_review_status.py new file mode 100644 index 000000000..d24738438 --- /dev/null +++ b/migrations/030_create_user_review_status.py @@ -0,0 +1,85 @@ +"""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()] + + for segment_id in reviewed_segment_ids: + for user in all_users: + UserReviewStatus.create( + user_id=user.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' + ) From bf311e6467a8400c92914ba467d7f85a72439e3d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 13 Mar 2025 16:01:15 -0500 Subject: [PATCH 64/82] Simplify auth check (#17138) * simplify get_current_user * add sanity check --- frigate/api/auth.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index c0ed94d5c..f806a0c30 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -189,21 +189,15 @@ def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, expiration, sec async def get_current_user(request: Request): - JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name - encoded_token = request.cookies.get(JWT_COOKIE_NAME) - if not encoded_token: - return JSONResponse(content={"message": "No JWT token found"}, status_code=401) + username = request.headers.get("remote-user") + role = request.headers.get("remote-role") - try: - token = jwt.decode(encoded_token, request.app.jwt_token) - if "sub" not in token.claims or "role" not in token.claims: - return JSONResponse( - content={"message": "Invalid JWT token"}, status_code=401 - ) - 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) + if not username or not role: + return JSONResponse( + content={"message": "No authorization headers."}, status_code=401 + ) + + return {"username": username, "role": role} def require_role(required_roles: List[str]): From 8a17e2bfbf4fb7d367e7cda67dee763351de906d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 14 Mar 2025 07:25:48 -0500 Subject: [PATCH 65/82] implement onvif retry mechanism (#17144) --- frigate/api/media.py | 4 +- frigate/ptz/onvif.py | 153 +++++++++++++++++++++++++++++++++---------- 2 files changed, 122 insertions(+), 35 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index b74ec93d1..83aa6c339 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -106,10 +106,10 @@ def imagestream( @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: return JSONResponse( - content=request.app.onvif.get_camera_info(camera_name), + content=await request.app.onvif.get_camera_info(camera_name), ) else: return JSONResponse( diff --git a/frigate/ptz/onvif.py b/frigate/ptz/onvif.py index 1a813c799..dea7f5b77 100644 --- a/frigate/ptz/onvif.py +++ b/frigate/ptz/onvif.py @@ -2,6 +2,7 @@ import asyncio import logging +import time from enum import Enum from importlib.util import find_spec from pathlib import Path @@ -39,6 +40,10 @@ class OnvifController: self, config: FrigateConfig, ptz_metrics: dict[str, PTZMetrics] ) -> None: 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.ptz_metrics = ptz_metrics @@ -47,26 +52,37 @@ class OnvifController: continue if cam.onvif.host: - try: - self.cams[cam_name] = { - "onvif": ONVIFCamera( - cam.onvif.host, - cam.onvif.port, - cam.onvif.user, - cam.onvif.password, - wsdl_dir=str( - Path(find_spec("onvif").origin).parent / "wsdl" - ), - adjust_time=cam.onvif.ignore_time_mismatch, - encrypt=not cam.onvif.tls_insecure, - ), - "init": False, - "active": False, - "features": [], - "presets": {}, - } - except ONVIFError as e: - logger.error(f"Onvif connection to {cam.name} failed: {e}") + result = self._create_onvif_camera(cam_name, cam) + if result: + self.cams[cam_name] = result + + def _create_onvif_camera(self, cam_name: str, cam) -> dict | None: + """Create an ONVIF camera instance and handle failures.""" + try: + return { + "onvif": ONVIFCamera( + cam.onvif.host, + cam.onvif.port, + cam.onvif.user, + cam.onvif.password, + wsdl_dir=str(Path(find_spec("onvif").origin).parent / "wsdl"), + adjust_time=cam.onvif.ignore_time_mismatch, + encrypt=not cam.onvif.tls_insecure, + ), + "init": False, + "active": False, + "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: onvif: ONVIFCamera = self.cams[camera_name]["onvif"] @@ -548,7 +564,7 @@ class OnvifController: self, camera_name: str, command: OnvifCommandEnum, param: str = "" ) -> None: 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 if not self.cams[camera_name]["init"]: @@ -576,23 +592,94 @@ class OnvifController: except ONVIFError as e: logger.error(f"Unable to handle onvif command: {e}") - 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}") + async def get_camera_info(self, camera_name: str) -> dict[str, any]: + """ + 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 {} - if not self.cams[camera_name]["init"]: - asyncio.run(self._init_onvif(camera_name)) + if camera_name not in self.cams and ( + 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 { - "name": camera_name, - "features": self.cams[camera_name]["features"], - "presets": list(self.cams[camera_name]["presets"].keys()), - } + if camera_name in self.cams and self.cams[camera_name]["init"]: + return { + "name": camera_name, + "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: 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 {} if not self.cams[camera_name]["init"]: @@ -622,7 +709,7 @@ class OnvifController: def get_camera_status(self, camera_name: str) -> None: 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 {} if not self.cams[camera_name]["init"]: From 759d963a04e6b4b457ad132447281852173c3c3a Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 14 Mar 2025 07:10:47 -0600 Subject: [PATCH 66/82] Cleanup typing (#17145) --- frigate/api/media.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index 83aa6c339..3c7fc09da 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -27,6 +27,7 @@ from frigate.api.defs.query.media_query_parameters import ( MediaRecordingsSummaryQueryParams, ) from frigate.api.defs.tags import Tags +from frigate.camera.state import CameraState from frigate.config import FrigateConfig from frigate.const import ( CACHE_DIR, @@ -765,31 +766,36 @@ def event_snapshot( except DoesNotExist: # see if the object is currently being tracked 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: if event_id in camera_state.tracked_objects: tracked_obj = camera_state.tracked_objects.get(event_id) 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, bounding_box=params.bbox, crop=params.crop, height=params.height, quality=params.quality, ) - except Exception: + except Exception as e: return JSONResponse( - content={"success": False, "message": "Event not found"}, + content={"success": False, "message": f"Ongoing event not found: {e}"}, status_code=404, ) - except Exception: + except Exception as e: return JSONResponse( - content={"success": False, "message": "Event not found"}, status_code=404 + content={"success": False, "message": f"Unknown error occurred: {e}"}, + status_code=404, ) if jpg_bytes is None: return JSONResponse( - content={"success": False, "message": "Event not found"}, status_code=404 + content={"success": False, "message": "Live frame not available"}, + status_code=404, ) headers = { From fbd22c8124d4980025ccc6d3a408731d3bfca7af Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 14 Mar 2025 07:21:50 -0600 Subject: [PATCH 67/82] Cleanup bird classification (#17146) * Cleanup bird classification * Cleanup --- frigate/api/media.py | 8 ++++---- frigate/data_processing/real_time/bird.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frigate/api/media.py b/frigate/api/media.py index 3c7fc09da..83307a15c 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -781,14 +781,14 @@ def event_snapshot( height=params.height, quality=params.quality, ) - except Exception as e: + except Exception: return JSONResponse( - content={"success": False, "message": f"Ongoing event not found: {e}"}, + content={"success": False, "message": "Ongoing event not found"}, status_code=404, ) - except Exception as e: + except Exception: return JSONResponse( - content={"success": False, "message": f"Unknown error occurred: {e}"}, + content={"success": False, "message": "Unknown error occurred"}, status_code=404, ) diff --git a/frigate/data_processing/real_time/bird.py b/frigate/data_processing/real_time/bird.py index d942edf6f..ba6d4f08c 100644 --- a/frigate/data_processing/real_time/bird.py +++ b/frigate/data_processing/real_time/bird.py @@ -115,10 +115,10 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): 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) - self.interpreter.set_tensor(self.tensor_input_details[0]["index"], input) self.interpreter.invoke() res: np.ndarray = self.interpreter.get_tensor( From fe078666c62ae93d95e30dcec797a40c1bf7dc98 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 14 Mar 2025 08:27:48 -0500 Subject: [PATCH 68/82] Ensure time range filter retains selected values (#17147) --- .../components/overlay/dialog/SearchFilterDialog.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/components/overlay/dialog/SearchFilterDialog.tsx b/web/src/components/overlay/dialog/SearchFilterDialog.tsx index 2768f8859..7fadc1dac 100644 --- a/web/src/components/overlay/dialog/SearchFilterDialog.tsx +++ b/web/src/components/overlay/dialog/SearchFilterDialog.tsx @@ -254,11 +254,13 @@ function TimeRangeFilterContent({ const [endOpen, setEndOpen] = useState(false); const [afterHour, beforeHour] = useMemo(() => { - if (!timeRange || !timeRange.includes(",")) { - return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE]; + if (Array.isArray(timeRange) && timeRange.length === 2) { + return timeRange; } - - return timeRange.split(","); + if (typeof timeRange === "string" && timeRange.includes(",")) { + return timeRange.split(","); + } + return [DEFAULT_TIME_RANGE_AFTER, DEFAULT_TIME_RANGE_BEFORE]; }, [timeRange]); const [selectedAfterHour, setSelectedAfterHour] = useState(afterHour); From 2313b8ea0588566ec8fb500fed2a47b1d6b0743c Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 14 Mar 2025 07:48:16 -0600 Subject: [PATCH 69/82] Fix bug where safari and firefox cache image for too long (#17148) --- web/src/components/camera/AutoUpdatingCameraImage.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/components/camera/AutoUpdatingCameraImage.tsx b/web/src/components/camera/AutoUpdatingCameraImage.tsx index d97a9214a..95d90d9bd 100644 --- a/web/src/components/camera/AutoUpdatingCameraImage.tsx +++ b/web/src/components/camera/AutoUpdatingCameraImage.tsx @@ -78,7 +78,10 @@ export default function AutoUpdatingCameraImage({ let baseParam = ""; 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 { baseParam = `cache=${key}`; } From d2368f5cbbbf6b08b68853aff01b5b6e26109a27 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 14 Mar 2025 10:09:16 -0500 Subject: [PATCH 70/82] Use a recent time window for areas for autotracking zooming (#17150) --- frigate/ptz/autotrack.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index c1184f5b5..81e54c6d7 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -584,19 +584,31 @@ class PtzAutoTracker: # Extract areas and calculate weighted average # 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 = [ { - "frame_time": obj["frame_time"], - "box": obj["box"], + "frame_time": entry["frame_time"], + "box": entry["box"], "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, } - 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 filtered_areas_not_touching_edge = [ From a995872d1c5948e326beb03ac2fcb1c90aa786cc Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 14 Mar 2025 10:23:37 -0600 Subject: [PATCH 71/82] Add face recognition and license plate recognition to settings UI (#17152) * Refactor explore settings to classification settings * Cleanup * Add face config section * Add license plate recognition to settings * Update face recognition docs * Fix variable usage * Fix typo * Update docs/docs/configuration/face_recognition.md Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Improve spacing and add face library to mobile * Clarify docs --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- docs/docs/configuration/face_recognition.md | 40 +- .../license_plate_recognition.md | 2 +- .../common/license_plate/mixin.py | 6 - web/src/components/menu/GeneralSettings.tsx | 13 + web/src/pages/FaceLibrary.tsx | 2 +- web/src/pages/Settings.tsx | 8 +- web/src/types/frigateConfig.ts | 3 +- .../settings/ClassificationSettingsView.tsx | 449 ++++++++++++++++++ web/src/views/settings/SearchSettingsView.tsx | 295 ------------ 9 files changed, 508 insertions(+), 310 deletions(-) create mode 100644 web/src/views/settings/ClassificationSettingsView.tsx delete mode 100644 web/src/views/settings/SearchSettingsView.tsx diff --git a/docs/docs/configuration/face_recognition.md b/docs/docs/configuration/face_recognition.md index 4d934afce..aac1be9b5 100644 --- a/docs/docs/configuration/face_recognition.md +++ b/docs/docs/configuration/face_recognition.md @@ -3,19 +3,55 @@ id: 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. +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 -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 face_recognition: 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 The number of images needed for a sufficient training set for face recognition varies depending on several factors: diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index 776f30cf9..b7fbcdea1 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -51,7 +51,7 @@ Fine-tune the LPR feature using these optional parameters: - **`detection_threshold`**: License plate object detection confidence score required before recognition runs. - 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. - Default: `1000` pixels. - Depending on the resolution of your camera's `detect` stream, you can increase this value to ignore small or distant plates. diff --git a/frigate/data_processing/common/license_plate/mixin.py b/frigate/data_processing/common/license_plate/mixin.py index 751a674f5..c07163819 100644 --- a/frigate/data_processing/common/license_plate/mixin.py +++ b/frigate/data_processing/common/license_plate/mixin.py @@ -937,12 +937,6 @@ class LicensePlateProcessingMixin: if not license_plate: 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") # check that license plate is valid diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index b07ace2a3..3d65cf8f0 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -255,6 +255,19 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { )} + {isAdmin && isMobile && ( + <> + + + + Configuration editor + + + + )} Appearance diff --git a/web/src/pages/FaceLibrary.tsx b/web/src/pages/FaceLibrary.tsx index fbb75c681..9099c4977 100644 --- a/web/src/pages/FaceLibrary.tsx +++ b/web/src/pages/FaceLibrary.tsx @@ -344,7 +344,7 @@ function TrainingGrid({ key={image} image={image} faceNames={faceNames} - threshold={config.face_recognition.threshold} + threshold={config.face_recognition.recognition_threshold} selected={selectedFaces.includes(image)} onClick={() => onClickFace(image)} onRefresh={onRefresh} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index bfc3f6f8e..8e0ead43a 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -35,7 +35,7 @@ import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import AuthenticationView from "@/views/settings/AuthenticationView"; import NotificationView from "@/views/settings/NotificationsSettingsView"; -import SearchSettingsView from "@/views/settings/SearchSettingsView"; +import ClassificationSettingsView from "@/views/settings/ClassificationSettingsView"; import UiSettingsView from "@/views/settings/UiSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchParams } from "react-router-dom"; @@ -46,7 +46,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin"; const allSettingsViews = [ "UI settings", - "explore settings", + "classification settings", "camera settings", "masks / zones", "motion tuner", @@ -245,8 +245,8 @@ export default function Settings() {
{page == "UI settings" && } - {page == "explore settings" && ( - + {page == "classification settings" && ( + )} {page == "debug" && ( diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 9b3d60606..2910118f4 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -333,7 +333,8 @@ export interface FrigateConfig { face_recognition: { enabled: boolean; - threshold: number; + detection_threshold: number; + recognition_threshold: number; }; ffmpeg: { diff --git a/web/src/views/settings/ClassificationSettingsView.tsx b/web/src/views/settings/ClassificationSettingsView.tsx new file mode 100644 index 000000000..f6ce3c37d --- /dev/null +++ b/web/src/views/settings/ClassificationSettingsView.tsx @@ -0,0 +1,449 @@ +import Heading from "@/components/ui/heading"; +import { FrigateConfig, SearchModelSize } from "@/types/frigateConfig"; +import useSWR from "swr"; +import axios from "axios"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useCallback, useContext, useEffect, useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; +import { Separator } from "@/components/ui/separator"; +import { Link } from "react-router-dom"; +import { LuExternalLink } from "react-icons/lu"; +import { StatusBarMessagesContext } from "@/context/statusbar-provider"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; + +type ClassificationSettings = { + search: { + enabled?: boolean; + reindex?: boolean; + model_size?: SearchModelSize; + }; + face: { + enabled?: boolean; + }; + lpr: { + enabled?: boolean; + }; +}; + +type ClassificationSettingsViewProps = { + setUnsavedChanges: React.Dispatch>; +}; +export default function ClassificationSettingsView({ + setUnsavedChanges, +}: ClassificationSettingsViewProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + const [changedValue, setChangedValue] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + + const [classificationSettings, setClassificationSettings] = + useState({ + search: { + enabled: undefined, + reindex: undefined, + model_size: undefined, + }, + face: { + enabled: undefined, + }, + lpr: { + enabled: undefined, + }, + }); + + const [origSearchSettings, setOrigSearchSettings] = + useState({ + search: { + enabled: undefined, + reindex: undefined, + model_size: undefined, + }, + face: { + enabled: undefined, + }, + lpr: { + enabled: undefined, + }, + }); + + useEffect(() => { + if (config) { + if (classificationSettings?.search.enabled == undefined) { + setClassificationSettings({ + search: { + enabled: config.semantic_search.enabled, + reindex: config.semantic_search.reindex, + model_size: config.semantic_search.model_size, + }, + face: { + enabled: config.face_recognition.enabled, + }, + lpr: { + enabled: config.lpr.enabled, + }, + }); + } + + setOrigSearchSettings({ + search: { + enabled: config.semantic_search.enabled, + reindex: config.semantic_search.reindex, + model_size: config.semantic_search.model_size, + }, + face: { + enabled: config.face_recognition.enabled, + }, + lpr: { + enabled: config.lpr.enabled, + }, + }); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config]); + + const handleClassificationConfigChange = ( + newConfig: Partial, + ) => { + setClassificationSettings((prevConfig) => ({ + search: { + ...prevConfig.search, + ...newConfig.search, + }, + face: { ...prevConfig.face, ...newConfig.face }, + lpr: { ...prevConfig.lpr, ...newConfig.lpr }, + })); + setUnsavedChanges(true); + setChangedValue(true); + }; + + const saveToConfig = useCallback(async () => { + setIsLoading(true); + + axios + .put( + `config/set?semantic_search.enabled=${classificationSettings.search.enabled ? "True" : "False"}&semantic_search.reindex=${classificationSettings.search.reindex ? "True" : "False"}&semantic_search.model_size=${classificationSettings.search.model_size}`, + { + requires_restart: 0, + }, + ) + .then((res) => { + if (res.status === 200) { + toast.success("Classification settings have been saved.", { + position: "top-center", + }); + setChangedValue(false); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + const errorMessage = + error.response?.data?.message || + error.response?.data?.detail || + "Unknown error"; + toast.error(`Failed to save config changes: ${errorMessage}`, { + position: "top-center", + }); + }) + .finally(() => { + setIsLoading(false); + }); + }, [updateConfig, classificationSettings.search]); + + const onCancel = useCallback(() => { + setClassificationSettings(origSearchSettings); + setChangedValue(false); + removeMessage("search_settings", "search_settings"); + }, [origSearchSettings, removeMessage]); + + useEffect(() => { + if (changedValue) { + addMessage( + "search_settings", + `Unsaved Classification settings changes`, + undefined, + "search_settings", + ); + } else { + removeMessage("search_settings", "search_settings"); + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changedValue]); + + useEffect(() => { + document.title = "Classification Settings - Frigate"; + }, []); + + if (!config) { + return ; + } + + return ( +
+ +
+ + Classification Settings + + + + Semantic Search + +
+
+

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

+ +
+ + Read the Documentation + + +
+
+
+ +
+
+ { + handleClassificationConfigChange({ + search: { enabled: isChecked }, + }); + }} + /> +
+ +
+
+
+
+ { + handleClassificationConfigChange({ + search: { reindex: isChecked }, + }); + }} + /> +
+ +
+
+
+ Re-indexing will reprocess all thumbnails and descriptions (if + enabled) and apply the embeddings on each startup.{" "} + Don't forget to disable the option after restarting! +
+
+
+
+
Model Size
+
+

+ The size of the model used for Semantic Search embeddings. +

+
    +
  • + Using small employs a quantized version of the + model that uses less RAM and runs faster on CPU with a very + negligible difference in embedding quality. +
  • +
  • + Using large employs the full Jina model and will + automatically run on the GPU if applicable. +
  • +
+
+
+ +
+
+ +
+ + + + 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. +

+ +
+ + Read the Documentation + + +
+
+
+ +
+
+ { + handleClassificationConfigChange({ + face: { enabled: isChecked }, + }); + }} + /> +
+ +
+
+
+ + + + + License Plate Recognition + +
+
+

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

+ +
+ + Read the Documentation + + +
+
+
+ +
+
+ { + handleClassificationConfigChange({ + lpr: { enabled: isChecked }, + }); + }} + /> +
+ +
+
+
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/web/src/views/settings/SearchSettingsView.tsx b/web/src/views/settings/SearchSettingsView.tsx deleted file mode 100644 index b3f35bde7..000000000 --- a/web/src/views/settings/SearchSettingsView.tsx +++ /dev/null @@ -1,295 +0,0 @@ -import Heading from "@/components/ui/heading"; -import { FrigateConfig, SearchModelSize } from "@/types/frigateConfig"; -import useSWR from "swr"; -import axios from "axios"; -import ActivityIndicator from "@/components/indicators/activity-indicator"; -import { useCallback, useContext, useEffect, useState } from "react"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { Toaster } from "@/components/ui/sonner"; -import { toast } from "sonner"; -import { Separator } from "@/components/ui/separator"; -import { Link } from "react-router-dom"; -import { LuExternalLink } from "react-icons/lu"; -import { StatusBarMessagesContext } from "@/context/statusbar-provider"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, -} from "@/components/ui/select"; - -type SearchSettingsViewProps = { - setUnsavedChanges: React.Dispatch>; -}; - -type SearchSettings = { - enabled?: boolean; - reindex?: boolean; - model_size?: SearchModelSize; -}; - -export default function SearchSettingsView({ - setUnsavedChanges, -}: SearchSettingsViewProps) { - const { data: config, mutate: updateConfig } = - useSWR("config"); - const [changedValue, setChangedValue] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; - - const [searchSettings, setSearchSettings] = useState({ - enabled: undefined, - reindex: undefined, - model_size: undefined, - }); - - const [origSearchSettings, setOrigSearchSettings] = useState({ - enabled: undefined, - reindex: undefined, - model_size: undefined, - }); - - useEffect(() => { - if (config) { - if (searchSettings?.enabled == undefined) { - setSearchSettings({ - enabled: config.semantic_search.enabled, - reindex: config.semantic_search.reindex, - model_size: config.semantic_search.model_size, - }); - } - - setOrigSearchSettings({ - enabled: config.semantic_search.enabled, - reindex: config.semantic_search.reindex, - model_size: config.semantic_search.model_size, - }); - } - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config]); - - const handleSearchConfigChange = (newConfig: Partial) => { - setSearchSettings((prevConfig) => ({ ...prevConfig, ...newConfig })); - setUnsavedChanges(true); - setChangedValue(true); - }; - - const saveToConfig = useCallback(async () => { - setIsLoading(true); - - axios - .put( - `config/set?semantic_search.enabled=${searchSettings.enabled ? "True" : "False"}&semantic_search.reindex=${searchSettings.reindex ? "True" : "False"}&semantic_search.model_size=${searchSettings.model_size}`, - { - requires_restart: 0, - }, - ) - .then((res) => { - if (res.status === 200) { - toast.success("Explore settings have been saved.", { - position: "top-center", - }); - setChangedValue(false); - updateConfig(); - } else { - toast.error(`Failed to save config changes: ${res.statusText}`, { - position: "top-center", - }); - } - }) - .catch((error) => { - const errorMessage = - error.response?.data?.message || - error.response?.data?.detail || - "Unknown error"; - toast.error(`Failed to save config changes: ${errorMessage}`, { - position: "top-center", - }); - }) - .finally(() => { - setIsLoading(false); - }); - }, [ - updateConfig, - searchSettings.enabled, - searchSettings.reindex, - searchSettings.model_size, - ]); - - const onCancel = useCallback(() => { - setSearchSettings(origSearchSettings); - setChangedValue(false); - removeMessage("search_settings", "search_settings"); - }, [origSearchSettings, removeMessage]); - - useEffect(() => { - if (changedValue) { - addMessage( - "search_settings", - `Unsaved Explore settings changes`, - undefined, - "search_settings", - ); - } else { - removeMessage("search_settings", "search_settings"); - } - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [changedValue]); - - useEffect(() => { - document.title = "Explore Settings - Frigate"; - }, []); - - if (!config) { - return ; - } - - return ( -
- -
- - Explore Settings - - - - Semantic Search - -
-
-

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

- -
- - Read the Documentation - - -
-
-
- -
-
- { - handleSearchConfigChange({ enabled: isChecked }); - }} - /> -
- -
-
-
-
- { - handleSearchConfigChange({ reindex: isChecked }); - }} - /> -
- -
-
-
- Re-indexing will reprocess all thumbnails and descriptions (if - enabled) and apply the embeddings on each startup.{" "} - Don't forget to disable the option after restarting! -
-
-
-
-
Model Size
-
-

- The size of the model used for Semantic Search embeddings. -

-
    -
  • - Using small employs a quantized version of the - model that uses less RAM and runs faster on CPU with a very - negligible difference in embedding quality. -
  • -
  • - Using large employs the full Jina model and will - automatically run on the GPU if applicable. -
  • -
-
-
- -
-
- - -
- - -
-
-
- ); -} From 5d524e8060ffc59de3bf13e9467cc02cef2ea620 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 14 Mar 2025 11:54:00 -0500 Subject: [PATCH 72/82] clarity for sub labels and recognized_license_plate (#17154) --- docs/docs/configuration/license_plate_recognition.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/configuration/license_plate_recognition.md b/docs/docs/configuration/license_plate_recognition.md index b7fbcdea1..ee490a7a6 100644 --- a/docs/docs/configuration/license_plate_recognition.md +++ b/docs/docs/configuration/license_plate_recognition.md @@ -58,9 +58,9 @@ Fine-tune the LPR feature using these optional parameters: ### 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`. -- **`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. - **`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" From 19342c87682de2fcf492265d231588b4fd596f83 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Fri, 14 Mar 2025 13:39:48 -0600 Subject: [PATCH 73/82] Fix face library menu item (#17155) --- web/src/components/menu/GeneralSettings.tsx | 7 ++++--- web/src/types/frigateConfig.ts | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index 3d65cf8f0..55d180a42 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -56,6 +56,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin"; import SetPasswordDialog from "../overlay/SetPasswordDialog"; import { toast } from "sonner"; import axios from "axios"; +import { FrigateConfig } from "@/types/frigateConfig"; type GeneralSettingsProps = { className?: string; @@ -63,7 +64,7 @@ type GeneralSettingsProps = { export default function GeneralSettings({ className }: GeneralSettingsProps) { const { data: profile } = useSWR("profile"); - const { data: config } = useSWR("config"); + const { data: config } = useSWR("config"); const logoutUrl = config?.proxy?.logout_url || "/api/logout"; const { theme, colorScheme, setTheme, setColorScheme } = useTheme(); @@ -255,7 +256,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { )} - {isAdmin && isMobile && ( + {isAdmin && isMobile && config?.face_recognition.enabled && ( <> - Configuration editor + Face Library diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 2910118f4..1b3dbd3f3 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -435,6 +435,10 @@ export interface FrigateConfig { enabled: boolean; }; + proxy: { + logout_url?: string; + }; + record: { enabled: boolean; enabled_in_config: boolean | null; From d87268acfebc1cefcef7ba9deb3f367696deb707 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 15 Mar 2025 08:11:45 -0500 Subject: [PATCH 74/82] Small tweaks (#17168) * Clean up repeated code in auth * Ensure review status is migrated for anonymous users --- frigate/api/auth.py | 2 +- migrations/030_create_user_review_status.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index f806a0c30..fc0bda6ed 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -253,12 +253,12 @@ def auth(request: Request): # pass the user header value from the upstream proxy if a mapping is specified # or use anonymous if none are specified user_header = proxy_config.header_map.user - role_header = proxy_config.header_map.role success_response.headers["remote-user"] = ( request.headers.get(user_header, default="anonymous") if user_header else "anonymous" ) + role_header = proxy_config.header_map.role role = ( request.headers.get(role_header, default="viewer") diff --git a/migrations/030_create_user_review_status.py b/migrations/030_create_user_review_status.py index d24738438..17f2b36b9 100644 --- a/migrations/030_create_user_review_status.py +++ b/migrations/030_create_user_review_status.py @@ -62,11 +62,13 @@ def migrate(migrator, database, fake=False, **kwargs): '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 user in all_users: + for username in usernames: UserReviewStatus.create( - user_id=user.username, + user_id=username, review_segment=segment_id, has_been_reviewed=True, ) From db541abed4c5dd0897df4f74296da8bfae031c4d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 16 Mar 2025 05:01:15 -0600 Subject: [PATCH 75/82] Fix model errors (#17171) --- frigate/config/config.py | 5 +++++ frigate/data_processing/real_time/bird.py | 3 ++- frigate/detectors/plugins/edgetpu_tfl.py | 15 ++++++++++++--- frigate/embeddings/__init__.py | 3 +++ frigate/record/export.py | 4 ---- 5 files changed, 22 insertions(+), 8 deletions(-) diff --git a/frigate/config/config.py b/frigate/config/config.py index 633aef803..ce59e6733 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -608,6 +608,11 @@ class FrigateConfig(FrigateBaseModel): self.model.create_colormap(sorted(self.objects.all_objects)) 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(): adapter = TypeAdapter(DetectorConfig) model_dict = ( diff --git a/frigate/data_processing/real_time/bird.py b/frigate/data_processing/real_time/bird.py index ba6d4f08c..ea181d6c3 100644 --- a/frigate/data_processing/real_time/bird.py +++ b/frigate/data_processing/real_time/bird.py @@ -144,7 +144,8 @@ class BirdRealTimeProcessor(RealTimeProcessorApi): return 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 diff --git a/frigate/detectors/plugins/edgetpu_tfl.py b/frigate/detectors/plugins/edgetpu_tfl.py index c320bd89b..246d2dd41 100644 --- a/frigate/detectors/plugins/edgetpu_tfl.py +++ b/frigate/detectors/plugins/edgetpu_tfl.py @@ -1,4 +1,5 @@ import logging +import os import numpy as np from pydantic import Field @@ -45,9 +46,17 @@ class EdgeTpuTfl(DetectionApi): experimental_delegates=[edge_tpu_delegate], ) except ValueError: - logger.error( - "No EdgeTPU was detected. If you do not have a Coral device yet, you must configure CPU detectors." - ) + _, ext = os.path.splitext(detector_config.model.path) + + 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 self.interpreter.allocate_tensors() diff --git a/frigate/embeddings/__init__.py b/frigate/embeddings/__init__.py index 56bd097d6..0a0d7200a 100644 --- a/frigate/embeddings/__init__.py +++ b/frigate/embeddings/__init__.py @@ -225,6 +225,9 @@ class EmbeddingsContext: if os.path.isfile(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: self.requestor.send_data( EmbeddingsRequestEnum.embed_description.value, diff --git a/frigate/record/export.py b/frigate/record/export.py index 0e64021b4..4eadbf1bd 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -19,7 +19,6 @@ from frigate.const import ( CACHE_DIR, CLIPS_DIR, EXPORT_DIR, - FFMPEG_HVC1_ARGS, MAX_PLAYLIST_SECONDS, PREVIEW_FRAME_TYPE, ) @@ -233,9 +232,6 @@ class RecordingExporter(threading.Thread): ) ).split(" ") - if self.config.ffmpeg.apple_compatibility: - ffmpeg_cmd += FFMPEG_HVC1_ARGS - # 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)}" ffmpeg_cmd.extend(["-metadata", f"title={title}"]) From d34533981f8262642576d5cc75eb85bd3b3575a7 Mon Sep 17 00:00:00 2001 From: GuoQing Liu <842607283@qq.com> Date: Sun, 16 Mar 2025 23:36:20 +0800 Subject: [PATCH 76/82] feat: add i18n (translation/localization) (#16877) * Translation module init * Add more i18n keys * fix: fix string wrong * refactor: use namespace translation file * chore: add more translation key * fix: fix some page name error * refactor: change Trans tag for t function * chore: fix some key not work * chore: fix SearchFilterDialog i18n key error Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * chore: fix en i18n file filter missing some keys * chore: add some i18n keys * chore: add more i18n keys again * feat: add search page i18n * feat: add explore model i18n keys * Update web/src/components/menu/GeneralSettings.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/components/menu/GeneralSettings.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/components/menu/GeneralSettings.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * feat: add more live i18n keys * feat: add more search setting i18n keys * fix: remove some comment * fix: fix some setting page url error * Update web/src/views/settings/SearchSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * fix: add system missing keys * fix: update password update i18n keys * chore: remove outdate translation.json file * fix: fix exploreSettings error * chore: add object setting i18n keys * Update web/src/views/recording/RecordingView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/public/locales/en/components/filter.json Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/components/overlay/ExportDialog.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * feat: add more i18n keys * fix: fix motionDetectionTuner html node * feat: add more page i18n keys * fix: cameraStream i18n keys error * feat: add Player i18n keys * feat: add more toast i18n keys * feat: change explore setting name * feat: add more document title i18n keys * feat: add more search i18n keys * fix: fix accessDenied i18n keys error * chore: add objectType i18n * chore: add inputWithTags i18n * chore: add SearchFilterDialog i18n * Update web/src/views/settings/ObjectSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/views/settings/ObjectSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/views/settings/ObjectSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/views/settings/ObjectSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * Update web/src/views/settings/ObjectSettingsView.tsx Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> * chore: add some missing i18n keys * chore: remove most import { t } from "i18next"; --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> --- web/package-lock.json | 143 +++++- web/package.json | 3 + web/public/locales/en/audio.json | 8 + web/public/locales/en/common.json | 173 +++++++ web/public/locales/en/components/auth.json | 15 + web/public/locales/en/components/camera.json | 77 ++++ web/public/locales/en/components/dialog.json | 96 ++++ web/public/locales/en/components/filter.json | 108 +++++ web/public/locales/en/components/icons.json | 8 + web/public/locales/en/components/input.json | 10 + web/public/locales/en/components/player.json | 39 ++ web/public/locales/en/objects.json | 104 +++++ web/public/locales/en/views/configEditor.json | 16 + web/public/locales/en/views/events.json | 35 ++ web/public/locales/en/views/explore.json | 176 +++++++ web/public/locales/en/views/exports.json | 18 + web/public/locales/en/views/faceLibrary.json | 41 ++ web/public/locales/en/views/live.json | 154 +++++++ web/public/locales/en/views/recording.json | 12 + web/public/locales/en/views/search.json | 65 +++ web/public/locales/en/views/settings.json | 435 ++++++++++++++++++ web/public/locales/en/views/system.json | 144 ++++++ web/public/locales/zh-CN/audio.json | 8 + web/public/locales/zh-CN/common.json | 173 +++++++ web/public/locales/zh-CN/components/auth.json | 15 + .../locales/zh-CN/components/camera.json | 77 ++++ .../locales/zh-CN/components/dialog.json | 96 ++++ .../locales/zh-CN/components/filter.json | 108 +++++ .../locales/zh-CN/components/icons.json | 8 + .../locales/zh-CN/components/input.json | 10 + .../locales/zh-CN/components/player.json | 39 ++ web/public/locales/zh-CN/objects.json | 104 +++++ .../locales/zh-CN/views/configEditor.json | 15 + web/public/locales/zh-CN/views/events.json | 35 ++ web/public/locales/zh-CN/views/explore.json | 175 +++++++ web/public/locales/zh-CN/views/exports.json | 17 + .../locales/zh-CN/views/faceLibrary.json | 41 ++ web/public/locales/zh-CN/views/live.json | 154 +++++++ web/public/locales/zh-CN/views/recording.json | 12 + web/public/locales/zh-CN/views/search.json | 65 +++ web/public/locales/zh-CN/views/settings.json | 433 +++++++++++++++++ web/public/locales/zh-CN/views/system.json | 144 ++++++ web/src/components/Statusbar.tsx | 15 +- web/src/components/auth/AuthForm.tsx | 22 +- .../components/button/DownloadVideoButton.tsx | 6 +- .../components/camera/DebugCameraImage.tsx | 25 +- web/src/components/card/AnimatedEventCard.tsx | 6 +- web/src/components/card/ExportCard.tsx | 14 +- web/src/components/card/ReviewCard.tsx | 78 ++-- web/src/components/card/SearchThumbnail.tsx | 5 +- .../components/card/SearchThumbnailFooter.tsx | 6 +- web/src/components/dynamic/NewReviewData.tsx | 6 +- web/src/components/dynamic/TimeAgo.tsx | 15 +- .../filter/CalendarFilterButton.tsx | 19 +- .../components/filter/CameraGroupSelector.tsx | 148 +++--- .../components/filter/CamerasFilterButton.tsx | 31 +- .../components/filter/LogSettingsButton.tsx | 22 +- .../components/filter/ReviewActionGroup.tsx | 44 +- .../components/filter/ReviewFilterGroup.tsx | 36 +- .../components/filter/SearchActionGroup.tsx | 42 +- .../components/filter/SearchFilterGroup.tsx | 62 +-- web/src/components/filter/ZoneMaskFilter.tsx | 20 +- web/src/components/graph/CameraGraph.tsx | 6 +- .../components/graph/CombinedStorageGraph.tsx | 31 +- web/src/components/icons/IconPicker.tsx | 13 +- web/src/components/input/InputWithTags.tsx | 146 +++--- web/src/components/input/SaveSearchDialog.tsx | 34 +- web/src/components/menu/AccountSettings.tsx | 35 +- web/src/components/menu/GeneralSettings.tsx | 204 +++++--- web/src/components/menu/LiveContextMenu.tsx | 75 +-- .../components/menu/SearchResultActions.tsx | 66 +-- web/src/components/mobile/MobilePage.tsx | 4 +- web/src/components/navigation/NavItem.tsx | 4 +- .../components/overlay/CameraInfoDialog.tsx | 64 ++- .../components/overlay/CreateUserDialog.tsx | 56 +-- .../components/overlay/DeleteUserDialog.tsx | 19 +- web/src/components/overlay/ExportDialog.tsx | 73 +-- web/src/components/overlay/GPUInfoDialog.tsx | 65 ++- .../components/overlay/MobileCameraDrawer.tsx | 4 +- .../overlay/MobileReviewSettingsDrawer.tsx | 55 ++- .../components/overlay/RoleChangeDialog.tsx | 31 +- .../components/overlay/SaveExportOverlay.tsx | 14 +- .../components/overlay/SetPasswordDialog.tsx | 55 ++- .../overlay/detail/AnnotationSettingsPane.tsx | 65 +-- .../overlay/detail/ObjectLifecycle.tsx | 35 +- .../overlay/detail/ReviewDetailDialog.tsx | 83 +++- .../overlay/detail/SearchDetailDialog.tsx | 182 +++++--- .../overlay/dialog/RestartDialog.tsx | 25 +- .../overlay/dialog/SearchFilterDialog.tsx | 105 +++-- .../overlay/dialog/TextEntryDialog.tsx | 8 +- web/src/components/player/HlsVideoPlayer.tsx | 6 +- web/src/components/player/LivePlayer.tsx | 20 +- web/src/components/player/PlayerStats.tsx | 38 +- web/src/components/player/PreviewPlayer.tsx | 11 +- .../player/PreviewThumbnailPlayer.tsx | 6 +- web/src/components/player/VideoControls.tsx | 11 +- .../player/dynamic/DynamicVideoPlayer.tsx | 4 +- .../settings/CameraStreamingDialog.tsx | 93 ++-- .../settings/MotionMaskEditPane.tsx | 70 +-- .../settings/ObjectMaskEditPane.tsx | 81 ++-- .../settings/PolygonEditControls.tsx | 18 +- web/src/components/settings/PolygonItem.tsx | 74 ++- .../components/settings/SearchSettings.tsx | 41 +- web/src/components/settings/ZoneEditPane.tsx | 164 ++++--- web/src/components/ui/calendar-range.tsx | 30 +- web/src/components/ui/calendar.tsx | 15 +- web/src/components/ui/carousel.tsx | 11 +- web/src/components/ui/pagination.tsx | 15 +- web/src/context/language-provider.tsx | 77 ++++ web/src/context/providers.tsx | 21 +- web/src/context/theme-provider.tsx | 4 +- web/src/hooks/use-camera-activity.ts | 2 +- web/src/hooks/use-navigation.ts | 14 +- web/src/hooks/use-stats.ts | 15 +- web/src/main.tsx | 2 + web/src/pages/AccessDenied.tsx | 12 +- web/src/pages/ConfigEditor.tsx | 30 +- web/src/pages/Events.tsx | 9 +- web/src/pages/Explore.tsx | 59 ++- web/src/pages/Exports.tsx | 26 +- web/src/pages/FaceLibrary.tsx | 73 +-- web/src/pages/Live.tsx | 15 +- web/src/pages/Logs.tsx | 57 ++- web/src/pages/NoMatch.tsx | 10 +- web/src/pages/Settings.tsx | 75 +-- web/src/pages/System.tsx | 16 +- web/src/utils/browserUtil.ts | 3 +- web/src/utils/i18n.ts | 93 ++++ web/src/utils/lifecycleUtil.ts | 60 ++- web/src/views/events/EventView.tsx | 46 +- web/src/views/explore/ExploreView.tsx | 19 +- web/src/views/live/DraggableGridLayout.tsx | 14 +- web/src/views/live/LiveBirdseyeView.tsx | 22 +- web/src/views/live/LiveCameraView.tsx | 310 ++++++++----- web/src/views/live/LiveDashboardView.tsx | 11 +- web/src/views/recording/RecordingView.tsx | 27 +- web/src/views/search/SearchView.tsx | 10 +- web/src/views/settings/AuthenticationView.tsx | 130 ++++-- web/src/views/settings/CameraSettingsView.tsx | 215 ++++++--- .../settings/ClassificationSettingsView.tsx | 110 ++--- web/src/views/settings/MasksAndZonesView.tsx | 76 +-- web/src/views/settings/MotionTunerView.tsx | 71 +-- .../settings/NotificationsSettingsView.tsx | 164 ++++--- web/src/views/settings/ObjectSettingsView.tsx | 130 ++---- web/src/views/settings/UiSettingsView.tsx | 122 ++--- web/src/views/system/CameraMetrics.tsx | 17 +- web/src/views/system/FeatureMetrics.tsx | 4 +- web/src/views/system/GeneralMetrics.tsx | 41 +- web/src/views/system/StorageMetrics.tsx | 29 +- ...tailwind.config.js => tailwind.config.cjs} | 0 150 files changed, 6810 insertions(+), 1927 deletions(-) create mode 100644 web/public/locales/en/audio.json create mode 100644 web/public/locales/en/common.json create mode 100644 web/public/locales/en/components/auth.json create mode 100644 web/public/locales/en/components/camera.json create mode 100644 web/public/locales/en/components/dialog.json create mode 100644 web/public/locales/en/components/filter.json create mode 100644 web/public/locales/en/components/icons.json create mode 100644 web/public/locales/en/components/input.json create mode 100644 web/public/locales/en/components/player.json create mode 100644 web/public/locales/en/objects.json create mode 100644 web/public/locales/en/views/configEditor.json create mode 100644 web/public/locales/en/views/events.json create mode 100644 web/public/locales/en/views/explore.json create mode 100644 web/public/locales/en/views/exports.json create mode 100644 web/public/locales/en/views/faceLibrary.json create mode 100644 web/public/locales/en/views/live.json create mode 100644 web/public/locales/en/views/recording.json create mode 100644 web/public/locales/en/views/search.json create mode 100644 web/public/locales/en/views/settings.json create mode 100644 web/public/locales/en/views/system.json create mode 100644 web/public/locales/zh-CN/audio.json create mode 100644 web/public/locales/zh-CN/common.json create mode 100644 web/public/locales/zh-CN/components/auth.json create mode 100644 web/public/locales/zh-CN/components/camera.json create mode 100644 web/public/locales/zh-CN/components/dialog.json create mode 100644 web/public/locales/zh-CN/components/filter.json create mode 100644 web/public/locales/zh-CN/components/icons.json create mode 100644 web/public/locales/zh-CN/components/input.json create mode 100644 web/public/locales/zh-CN/components/player.json create mode 100644 web/public/locales/zh-CN/objects.json create mode 100644 web/public/locales/zh-CN/views/configEditor.json create mode 100644 web/public/locales/zh-CN/views/events.json create mode 100644 web/public/locales/zh-CN/views/explore.json create mode 100644 web/public/locales/zh-CN/views/exports.json create mode 100644 web/public/locales/zh-CN/views/faceLibrary.json create mode 100644 web/public/locales/zh-CN/views/live.json create mode 100644 web/public/locales/zh-CN/views/recording.json create mode 100644 web/public/locales/zh-CN/views/search.json create mode 100644 web/public/locales/zh-CN/views/settings.json create mode 100644 web/public/locales/zh-CN/views/system.json create mode 100644 web/src/context/language-provider.tsx create mode 100644 web/src/utils/i18n.ts rename web/{tailwind.config.js => tailwind.config.cjs} (100%) diff --git a/web/package-lock.json b/web/package-lock.json index 97a0d991b..986677695 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -41,6 +41,8 @@ "embla-carousel-react": "^8.2.0", "framer-motion": "^11.5.4", "hls.js": "^1.5.20", + "i18next": "^24.2.0", + "i18next-http-backend": "^3.0.1", "idb-keyval": "^6.2.1", "immer": "^10.1.1", "konva": "^9.3.18", @@ -56,6 +58,7 @@ "react-dom": "^18.3.1", "react-grid-layout": "^1.5.0", "react-hook-form": "^7.52.1", + "react-i18next": "^15.2.0", "react-icons": "^5.5.0", "react-konva": "^18.2.10", "react-router-dom": "^6.26.0", @@ -192,9 +195,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", - "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -4306,6 +4310,15 @@ "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": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -5427,6 +5440,15 @@ "dev": true, "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": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5455,6 +5477,46 @@ "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": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", @@ -6359,6 +6421,48 @@ "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": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", @@ -7151,6 +7255,28 @@ "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": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -8455,7 +8581,7 @@ "version": "5.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "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": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", diff --git a/web/package.json b/web/package.json index 59a0a5d03..37233a976 100644 --- a/web/package.json +++ b/web/package.json @@ -47,6 +47,8 @@ "embla-carousel-react": "^8.2.0", "framer-motion": "^11.5.4", "hls.js": "^1.5.20", + "i18next": "^24.2.0", + "i18next-http-backend": "^3.0.1", "idb-keyval": "^6.2.1", "immer": "^10.1.1", "konva": "^9.3.18", @@ -62,6 +64,7 @@ "react-dom": "^18.3.1", "react-grid-layout": "^1.5.0", "react-hook-form": "^7.52.1", + "react-i18next": "^15.2.0", "react-icons": "^5.5.0", "react-konva": "^18.2.10", "react-router-dom": "^6.26.0", diff --git a/web/public/locales/en/audio.json b/web/public/locales/en/audio.json new file mode 100644 index 000000000..38dea5f2c --- /dev/null +++ b/web/public/locales/en/audio.json @@ -0,0 +1,8 @@ +{ + "crying": "Crying", + "laughter": "Laughter", + "scream": "Scream", + "speech": "Speech", + "yell": "Yell", + "fire_alarm": "Fire alarm" +} diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json new file mode 100644 index 000000000..ce88e4f72 --- /dev/null +++ b/web/public/locales/en/common.json @@ -0,0 +1,173 @@ +{ + "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": "s", + "second": "{{time}} seconds", + "formattedTimestamp": "%b %-d, %I:%M:%S %p", + "formattedTimestamp.24hour": "%b %-d, %H:%M:%S", + "formattedTimestamp2": "%m/%d %I:%M:%S%P", + "formattedTimestamp2.24hour": "%d %b %H:%M:%S", + "formattedTimestampExcludeSeconds": "%b %-d, %I:%M %p", + "formattedTimestampExcludeSeconds.24hour": "%b %-d, %H:%M", + "formattedTimestampWithYear": "%b %-d %Y, %I:%M %p", + "formattedTimestampWithYear.24hour": "%b %-d %Y, %H:%M", + "formattedTimestampOnlyMonthAndDay": "%b %-d" + }, + "unit": { + "speed": { + "mph": "mph", + "kph": "kph" + } + }, + "label": { + "back": "Go back" + }, + "button": { + "apply": "Apply", + "reset": "Reset", + "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", + "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" + }, + "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 languag" + }, + "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.label": "Frigate documentation", + "documentation": "Documentation", + "restart": "Restart Frigate", + "live": "Live", + "live.allCameras": "All Cameras", + "live.cameras": "Cameras", + "live.cameras.count_one": "{{count}} Camera", + "live.cameras.count_other": "{{count}} Cameras", + "review": "Review", + "explore": "Explore", + "export": "Export", + "uiPlayground": "UI Playground", + "faceLibrary": "Face Library", + "user": { + "account": "Account", + "current": "Current User: {{user}}", + "anonymous": "anonymous", + "logout": "Logout", + "setPassword": "Set Password" + } + }, + "toast": { + "copyUrlToClipboard": "Copied URL to clipboard.", + "save": { + "error": "Failed to save config changes: {{errorMessage}}", + "error.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": "Previous", + "previous.label": "Go to previous page", + "next": "Next", + "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}}" +} diff --git a/web/public/locales/en/components/auth.json b/web/public/locales/en/components/auth.json new file mode 100644 index 000000000..bec032838 --- /dev/null +++ b/web/public/locales/en/components/auth.json @@ -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.", + "webUnkownError": "Unknown error. Check console logs." + } + } +} \ No newline at end of file diff --git a/web/public/locales/en/components/camera.json b/web/public/locales/en/components/camera.json new file mode 100644 index 000000000..049384a42 --- /dev/null +++ b/web/public/locales/en/components/camera.json @@ -0,0 +1,77 @@ +{ + "group": { + "label": "Camera Groups", + "add": "Add camera groups", + "edit": "Edit camera groups", + "delete": { + "label": "Delete Camera Group", + "confirm": "Confirm Delete", + "confirm.desc": "Are you sure you want to delete the camera group {{name}}?" + }, + "name": { + "label": "Name", + "placeholder": "Enter a name...", + "error": { + "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. These settings are device/browser-specific.", + "audioIsAvailable": "Audio is available for this stream", + "audioIsUnavailable": "Audio is available for this stream", + "audio": { + "tips": "Audio must be output from your camera and configured in go2rtc for this stream.", + "tips.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": "Camera image will always be a live stream when visible on the dashboard, even if no activity is being detected.", + "desc.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" + } +} diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json new file mode 100644 index 000000000..c58faca21 --- /dev/null +++ b/web/public/locales/en/components/dialog.json @@ -0,0 +1,96 @@ +{ + "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_one": "This is not a {{label}}", + "false_other": "This is not an {{label}}", + "false.label": "Do not confirm this label for Frigate Plus", + "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": "Start Time", + "start.label": "Select Start Time", + "end": "End Time", + "end.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": { + "NotEnabled": "Restreaming is not enabled for this camera.", + "desc": "Set up go2rtc for additional live view options and audio for this camera.", + "desc.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": "Are you sure you want to delete all recorded video associated with this review item?

Hold the Shift key to bypass this dialog in the future.", + "desc.selected": "Are you sure you want to delete all recorded video associated with this review item?

Hold the Shift key to bypass this dialog in the future." + }, + "button": { + "export": "Export", + "markAsReviewed": "Mark as reviewed", + "deleteNow": "Delete Now" + } + } +} diff --git a/web/public/locales/en/components/filter.json b/web/public/locales/en/components/filter.json new file mode 100644 index 000000000..78188452b --- /dev/null +++ b/web/public/locales/en/components/filter.json @@ -0,0 +1,108 @@ +{ + "filter": "Filter", + "labels": { + "label": "Labels", + "all": "All Labels", + "all.short": "Labels", + "count": "{{count}} Labels" + }, + "zones": { + "all": "All Zones", + "all.short": "Zones" + }, + "dates": { + "all": "All Dates", + "all.short": "Dates" + }, + "more": "More Filters", + "reset.label": "Reset filters to default values", + "timeRange": "Time Range", + "zones.label": "Zones", + "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.

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": "All Cameras", + "all.short": "Cameras" + }, + "review": { + "showReviewed": "Show Reviewed" + }, + "motion": { + "showMotionOnly": "Show Motion Only" + }, + "explore": { + "settings": { + "title": "Settings", + "defaultView": "Default View", + "defaultView.desc": "When no filters are selected, display a summary of the most recent tracked objects per label, or display an unfiltered grid.", + "defaultView.summary": "Summary", + "defaultView.unfilteredGrid": "Unfiltered Grid", + "gridColumns": "Grid Columns", + "gridColumns.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": "Loading", + "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 NOT be deleted.

Are you sure you want to proceed?

Hold the Shift 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." + } +} diff --git a/web/public/locales/en/components/icons.json b/web/public/locales/en/components/icons.json new file mode 100644 index 000000000..22ef67e97 --- /dev/null +++ b/web/public/locales/en/components/icons.json @@ -0,0 +1,8 @@ +{ + "iconPicker": { + "selectIcon": "Select an icon", + "search": { + "placeholder": "Search for an icon..." + } + } +} \ No newline at end of file diff --git a/web/public/locales/en/components/input.json b/web/public/locales/en/components/input.json new file mode 100644 index 000000000..6cb0ea10a --- /dev/null +++ b/web/public/locales/en/components/input.json @@ -0,0 +1,10 @@ +{ + "button": { + "downloadVideo": { + "label": "Download Video", + "toast": { + "success": "Your review item video has started downloading." + } + } + } +} \ No newline at end of file diff --git a/web/public/locales/en/components/player.json b/web/public/locales/en/components/player.json new file mode 100644 index 000000000..c5e9b6662 --- /dev/null +++ b/web/public/locales/en/components/player.json @@ -0,0 +1,39 @@ +{ + "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}} detect stream, check error logs" + }, + "cameraDisabled": "Camera is disabled", + "stats": { + "streamType": "Stream Type:", + "streamType.short": "Type", + "bandwidth": "Bandwidth:", + "bandwidth.short": "Bandwidth", + "latency": "Latency:", + "latency.short": "Latency", + "latency.value": "{{secounds}} seconds", + "latency.short.value": "{{secounds}} sec", + "totalFrames": "Total Frames:", + "droppedFrames": "Dropped Frames:", + "droppedFrames.short": "Dropped", + "droppedFrames.short.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+" + } + } +} \ No newline at end of file diff --git a/web/public/locales/en/objects.json b/web/public/locales/en/objects.json new file mode 100644 index 000000000..01e6d428a --- /dev/null +++ b/web/public/locales/en/objects.json @@ -0,0 +1,104 @@ +{ + "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" +} diff --git a/web/public/locales/en/views/configEditor.json b/web/public/locales/en/views/configEditor.json new file mode 100644 index 000000000..eca79fcba --- /dev/null +++ b/web/public/locales/en/views/configEditor.json @@ -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" + } + + } +} \ No newline at end of file diff --git a/web/public/locales/en/views/events.json b/web/public/locales/en/views/events.json new file mode 100644 index 000000000..332817b00 --- /dev/null +++ b/web/public/locales/en/views/events.json @@ -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" +} diff --git a/web/public/locales/en/views/explore.json b/web/public/locales/en/views/explore.json new file mode 100644 index 000000000..a156afbbb --- /dev/null +++ b/web/public/locales/en/views/explore.json @@ -0,0 +1,176 @@ +{ + "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": "Show All Zones", + "showAllZones.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 annotation_offset field can be used to adjust this.", + "documentation": "Read the documentation ", + "millisecondsToOffset": "Milliseconds to offset detect annotations by. Default: 0", + "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: {{objects}}" + }, + "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", + "editSubLable": "Edit sub label", + "editSubLable.desc": "Enter a new sub label for this {{label}}", + "editSubLable.desc.noLabel": "Enter a new sub label for this tracked object", + "topScore": "Top Score", + "topScore.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" + }, + "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." + }, + "button.regenerate": "Regenerate", + "button.regenerate.label": "Regenerate tracked object description", + "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 NOT be deleted.

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}}" + } + } + } +} diff --git a/web/public/locales/en/views/exports.json b/web/public/locales/en/views/exports.json new file mode 100644 index 000000000..0dba02a29 --- /dev/null +++ b/web/public/locales/en/views/exports.json @@ -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}}" + } + } + +} \ No newline at end of file diff --git a/web/public/locales/en/views/faceLibrary.json b/web/public/locales/en/views/faceLibrary.json new file mode 100644 index 000000000..566c741e2 --- /dev/null +++ b/web/public/locales/en/views/faceLibrary.json @@ -0,0 +1,41 @@ +{ + "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" + }, + "train": { + "title": "Train", + "aria": "Select train" + }, + "selectItem": "Select {{item}}", + "button": { + "deleteFaceAttempts": "Delete Face Attempts", + "addFace": "Add Face", + "uploadImage": "Upload Image", + "reprocessFace:": "Reprocess Face" + }, + "trainFaceAs:": "Train Face as:", + "trainFaceAsPerson:": "Train Face as Person", + + "toast": { + "success": { + "uploadedImage": "Successfully uploaded image.", + "addFaceLibrary": "Successfully add 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}}" + } + } +} \ No newline at end of file diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json new file mode 100644 index 000000000..0c31d4833 --- /dev/null +++ b/web/public/locales/en/views/live.json @@ -0,0 +1,154 @@ +{ + "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": "Audio must be output from your camera and configured in go2rtc for this stream.", + "tips.documentation": "Read the documentation ", + "available": "Audio is available for this stream", + "unavailable": "Audio is not available for this stream" + }, + "twoWayTalk": { + "tips": "Your device must suppport 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 mode: {{effectiveRetainMode}}, so this on-demand recording will only keep segments with {{effectiveRetainModeName}}." + }, + "editLayout": { + "label": "Edit Layout", + "group.label": "Edit Camera Group", + "exitEdit": "Exit Editing" + } +} diff --git a/web/public/locales/en/views/recording.json b/web/public/locales/en/views/recording.json new file mode 100644 index 000000000..9033d7819 --- /dev/null +++ b/web/public/locales/en/views/recording.json @@ -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" + } + } +} \ No newline at end of file diff --git a/web/public/locales/en/views/search.json b/web/public/locales/en/views/search.json new file mode 100644 index 000000000..b63508fe9 --- /dev/null +++ b/web/public/locales/en/views/search.json @@ -0,0 +1,65 @@ +{ + "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": "Filters help you narrow down your search results. Here's how to use them in the input field:", + "desc.step": "
  • Type a filter name followed by a colon (e.g., \"cameras:\").
  • Select a value from the suggestions or type your own.
  • Use multiple filters by adding them one after another with a space in between.
  • Date filters (before: and after:) use {{DateFormat}} format.
  • Time range filter uses {{exampleTime}} format.
  • Remove filters by clicking the 'x' next to them.
", + "desc.example": "Example: cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM " + }, + "header": { + "currentFilterType": "Filter Values", + "noFilters": "Filters", + "activeFilters": "Active Filters" + } + }, + "similaritySearch": { + "title": "Similarity Search", + "active": "Similarity search active", + "clear": "Clear similarity search" + }, + "placeholder": { + "search": "Search..." + } +} \ No newline at end of file diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json new file mode 100644 index 000000000..3d4b6900c --- /dev/null +++ b/web/public/locales/en/views/settings.json @@ -0,0 +1,435 @@ +{ + "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" + }, + "menu": { + "uiSettings": "UI Settings", + "classificationSettings": "Classification Settings", + "cameraSettings": "Camera Settings", + "masksAndZones": "Masks / Zones", + "motionTuner": "Motion Tuner", + "debug": "Debug", + "users": "Users", + "notifications": "Notifications" + }, + "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. Don't forget to disable the option after restarting!" + }, + "modelSize": { + "label": "Model Size", + "desc": "The size of the model used for semantic search embeddings.", + "small": "small", + "large": "large", + "small.desc": "Using small 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.desc": "Using large 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.
Note: This does not disable go2rtc restreams." + }, + "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 person and car 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": "All {{detectionsLabels}} objects not categorized in {{zone}} on {{cameraName}} will be shown as Detections.", + "zoneObjectDetectionsTips.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.", + "zoneObjectDetectionsTips.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": "Distance must be greater than or equal to 0.1.", + "distance.error.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}} {{name}}?", + "success": "{{name}} has been deleted." + }, + "error": { + "mustBeFinished": "Polygon drawing must be finished before saving." + } + } + }, + "zones": { + "label": "Zones", + "documentTitle": "Edit Zone - Frigate", + "desc": "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.", + "desc.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": "Name", + "name.inputPlaceHolder": "Enter a name...", + "name.tips": "Name must be at least 2 characters and must not be the name of a camera or another zone.", + "inertia": "Inertia", + "inertia.desc": "Specifies how many frames that an object must be in a zone before they are considered in the zone. Default: 3", + "loiteringTime": "Loitering Time", + "loiteringTime.desc": "Sets a minimum amount of time in seconds that the object must be in the zone for it to activate. Default: 0", + "objects": "Objects", + "objects.desc": "List of objects that apply to this zone.", + "allObjects": "All Objects", + "speedEstimation": "Speed Estimation", + "speedEstimation.desc": "Enable speed estimation for objects in this zone. The zone must have exactly 4 points.", + "speedThreshold": "Speed Threshold ({{unit}})", + "speedThreshold.desc": "Specifies a minimum speed for objects to be considered in this zone.", + "speedThreshold.toast.error.pointLengthError": "Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.", + "speedThreshold.toast.error.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": "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.", + "desc.documentation": "Documentation", + "add": "New Motion Mask", + "edit": "Edit Motion Mask", + "context": "Motion masks are used to prevent unwanted types of motion from triggering detection (example: tree branches, camera timestamps). Motion masks should be used very sparingly, over-masking will make it more difficult for objects to be tracked.", + "context.documentation": "Read the documentation", + "point_one": "{{count}} point", + "point_other": "{{count}} points", + "clickDrawPolygon": "Click to draw a polygon on the image.", + "polygonAreaTooLarge": "The motion mask is covering {{polygonArea}}% of the camera frame. Large motion masks are not recommended.", + "polygonAreaTooLarge.tips": "Motion masks do not prevent objects from being detected. You should use a required zone instead.", + "polygonAreaTooLarge.documentation": "Read the documentation", + "toast.success": "{{polygonName}} has been saved. Restart Frigate to apply changes.", + "toast.success.noName": "Motion Mask has been saved. Restart Frigate to apply changes." + }, + "objectMasks": { + "label": "Object Masks", + "documentTitle": "Edit Object Mask - Frigate", + "desc": "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": "Objects", + "objects.desc": "The object type that that applies to this object mask.", + "objects.allObjectTypes": "All object types", + "toast.success": "{{polygonName}} has been saved. Restart Frigate to apply changes.", + "toast.success.noName": "Object Mask has been saved. Restart Frigate to apply changes." + } + }, + "motionDetectionTuner": { + "title": "Motion Detection Tuner", + "desc": "Frigate uses motion detection as a first line check to see if there is anything happening in the frame worth checking with object detection.", + "desc.documentation": "Read the Motion Tuning Guide", + "Threshold": "Threshold", + "Threshold.desc": "The threshold value dictates how much of a change in a pixel's luminance is required to be considered motion. Default: 30", + "contourArea": "Contour Area", + "contourArea.desc": "The contour area value is used to decide which groups of changed pixels qualify as motion. Default: 10", + "improveContrast": "Improve Contrast", + "improveContrast.desc": "Improve contrast for darker scenes. Default: ON", + "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": "
  • At startup, different colors will be assigned to each object label
  • A dark blue thin line indicates that object is not detected at this current point in time
  • A gray thin line indicates that object is detected as being stationary
  • A thick line indicates that object is the subject of autotracking (when enabled)
  • " + } + }, + "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": "

    Motion Boxes


    Red boxes will be overlaid on areas of the frame where motion is currently being detected

    " + }, + "regions": { + "title": "Regions", + "desc": "Show a box of the region of interest sent to the object detector", + "tips": "

    Region Boxes


    Bright green boxes will be overlaid on areas of interest in the frame that are being sent to the object detector.

    " + }, + "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": "Users Management", + "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": "Username", + "user.desc": "Only letters, numbers, periods and underscores allowed.", + "user.placeholder": "Enter username", + "password": "Password", + "password.placeholder": "Enter password", + "password.confirm": "Confirm Password", + "password.confirm.placeholder": "Confirm Password", + "password.strength": "password strength: ", + "password.strength.weak": "Weak", + "password.strength.medium": "Medium", + "password.strength.strong": "Strong", + "password.strength.veryStrong": "Very strong", + "password.match": "Passwords match", + "password.notMatch": "Passwords don't match", + "newPassword": "New Password", + "newPassword.placeholder": "Enter new password", + "newPassword.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 {{username}}?" + }, + "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 {{username}}", + "roleInfo": "

    Select the appropriate role for this user:

    • Admin: Full access to all features.
    • Viewer: Limited to Live dashboards, Review, Explore, and Exports only.
    " + } + } + }, + "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 (https://...). 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": "Email", + "email.placeholder": "e.g. example@email.com", + "email.desc": "A valid email is required and will be used to notify you if there are any issues with the push service.", + "cameras": "Cameras", + "cameras.noCameras": "No cameras available", + "cameras.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." + } + } + } +} diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json new file mode 100644 index 000000000..cbcf80995 --- /dev/null +++ b/web/public/locales/en/views/system.json @@ -0,0 +1,144 @@ +{ + "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", + "unused": "Unused", + "unusedStorageInformation": "Unused Storage Information", + "storageUsed": "Storage Used", + "percentageOfTotalUsed": "Percentage of Total Used", + "bandwidth": "Bandwidth", + "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 ffprobe.", + "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" + } +} diff --git a/web/public/locales/zh-CN/audio.json b/web/public/locales/zh-CN/audio.json new file mode 100644 index 000000000..f9438a747 --- /dev/null +++ b/web/public/locales/zh-CN/audio.json @@ -0,0 +1,8 @@ +{ + "crying": "哭泣", + "laughter": "笑声", + "scream": "尖叫", + "speech": "谈话", + "yell": "大喊", + "fire_alarm": "火灾警报器" +} diff --git a/web/public/locales/zh-CN/common.json b/web/public/locales/zh-CN/common.json new file mode 100644 index 000000000..16944e264 --- /dev/null +++ b/web/public/locales/zh-CN/common.json @@ -0,0 +1,173 @@ +{ + "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": "%m月%-d日 %I:%M:%S %p", + "formattedTimestamp.24hour": "%m月%-d日 %H:%M:%S", + "formattedTimestamp2": "%m/%d %I:%M:%S%P", + "formattedTimestamp2.24hour": "%d日%m月 %H:%M:%S", + "formattedTimestampExcludeSeconds": "%m月%-d日 %I:%M %p", + "formattedTimestampExcludeSeconds.24hour": "%m月%-d日 %H:%M", + "formattedTimestampWithYear": "%Y年%m月%-d日 %I:%M:%S %p", + "formattedTimestampWithYear.24hour": "%Y年%m月%-d日 %H:%M", + "formattedTimestampOnlyMonthAndDay": "%m月%-d日" + }, + "unit": { + "speed": { + "mph": "英里/小时", + "kph": "公里/小时" + } + }, + "label": { + "back": "返回" + }, + "pagination": { + "label": "分页", + "previous": "上一页", + "previous.label": "转到上一页", + "next": "下一页", + "next.label": "转到下一页", + "more": "更多页面" + }, + "button": { + "apply": "应用", + "reset": "重置", + "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": "立即删除" + }, + "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.label": "Frigate 的官方文档", + "documentation": "文档", + "live": "实时监控", + "live.allCameras": "所有摄像头", + "live.cameras": "摄像头", + "live.cameras.count_one": "{{count}} 个摄像头", + "live.cameras.count_other": "{{count}} 个摄像头", + "review": "回放", + "explore": "探测", + "export": "导出", + "uiPlayground": "UI Playground", + "faceLibrary": "人脸管理", + "user": { + "account": "账号", + "current": "当前用户:{{user}}", + "anonymous": "匿名", + "logout": "登出", + "setPassword": "设置密码" + }, + "restart": "重启 Frigate" + }, + "toast": { + "copyUrlToClipboard": "已复制链接到剪贴板。", + "save": { + "error": "保存配置信息失败: {{errorMessage}}", + "error.noMessage": "保存配置信息失败" + } + }, + "role": { + "title": "权限组", + "admin": "管理员", + "viewer": "查看者", + "desc": "管理员可以完全访问 Frigate UI 的所有功能。查看者则仅限于在 UI 中查看摄像头、审核项和历史录像。" + }, + "accessDenied": { + "documentTitle": "没有权限 - Frigate", + "title": "没有权限", + "desc": "您没有权限查看此页面。" + }, + "notFound": { + "documentTitle": "没有找到页面 - Frigate", + "title": "404", + "desc": "页面未找到" + }, + "selectItem": "选择 {{item}}" +} diff --git a/web/public/locales/zh-CN/components/auth.json b/web/public/locales/zh-CN/components/auth.json new file mode 100644 index 000000000..3e9a163b7 --- /dev/null +++ b/web/public/locales/zh-CN/components/auth.json @@ -0,0 +1,15 @@ +{ + "form": { + "user": "用户名", + "password": "密码", + "login": "登录", + "errors": { + "usernameRequired": "用户名不能为空", + "passwordRequired": "密码不能为空", + "rateLimit": "超出请求限制,请稍后再试。", + "loginFailed": "登录失败", + "unknownError": "未知错误,请检查日志。", + "webUnkownError": "未知错误,请检查控制台日志。" + } + } +} \ No newline at end of file diff --git a/web/public/locales/zh-CN/components/camera.json b/web/public/locales/zh-CN/components/camera.json new file mode 100644 index 000000000..c8a95adec --- /dev/null +++ b/web/public/locales/zh-CN/components/camera.json @@ -0,0 +1,77 @@ +{ + "group": { + "label": "摄像头组", + "add": "添加摄像头组", + "edit": "编辑摄像头组", + "delete": { + "label": "删除摄像头组", + "confirm": "确认删除", + "confirm.desc": "你确定要删除摄像头组 {{name}} 吗?" + }, + "name": { + "label": "名称", + "placeholder": "请输入名称", + "error": { + "mustLeastCharacters": "摄像头组的名称必须至少有 2 个字符。", + "exists": "摄像头组名称已存在。", + "nameMustNotPeriod": "摄像头组名称不能包含英文句号(.)。", + "invalid": "无效的摄像头组名称。" + } + }, + "cameras": { + "label": "摄像头", + "desc": "选择添加至该组的摄像头。" + }, + "icon": "图标", + "success": "摄像头组({{name}})保存成功。", + "camera": { + "setting": { + "label": "摄像头视频流设置", + "title": "{{cameraName}} 视频流设置", + "desc": "更改此摄像头组仪表板的实时视频流选项。这些设置特定于设备/浏览器。", + "audioIsAvailable": "此视频流支持音频", + "audioIsUnavailable": "此视频流不支持音频", + "audio": { + "tips": "音频必须从您的摄像头输出并在 go2rtc 中配置此流。", + "tips.document": "阅读文档(英文) " + }, + "streamMethod": { + "label": "视频流方法", + "method": { + "noStreaming": { + "label": "无视频流", + "desc": "摄像头图像每分钟仅更新一次,不会进行实时视频流播放。" + }, + "smartStreaming": { + "label": "智能视频流(推荐)", + "desc": "智能视频流在没有检测到活动时,每分钟更新一次摄像头图像,以节省带宽和资源。当检测到活动时,图像会无缝切换到实时视频流。" + }, + "continuousStreaming": { + "label": "持续视频流", + "desc": "当摄像头画面在仪表板上可见时,始终为实时视频流,即使未检测到活动。", + "desc.warning": "持续视频流可能会导致高带宽使用和性能问题,请谨慎使用。" + } + } + }, + "compatibilityMode": { + "label": "兼容模式", + "desc": "仅在摄像头的实时视频流显示颜色伪影,并且图像右侧有一条对角线时启用此选项。" + } + } + } + }, + "debug": { + "options": { + "label": "设置", + "title": "选项", + "showOptions": "显示选项", + "hideOptions": "隐藏选项" + }, + "boundingBox": "边界框", + "timestamp": "时间戳", + "zones": "区域", + "mask": "遮罩", + "motion": "运动", + "regions": "区域" + } +} diff --git a/web/public/locales/zh-CN/components/dialog.json b/web/public/locales/zh-CN/components/dialog.json new file mode 100644 index 000000000..54a669f17 --- /dev/null +++ b/web/public/locales/zh-CN/components/dialog.json @@ -0,0 +1,96 @@ +{ + "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": "开始时间", + "start.label": "选择开始时间", + "end": "结束时间", + "end.label": "选择结束时间" + }, + "name": { + "placeholder": "导出项目的名字" + }, + "select": "选择", + "export": "导出", + "selectOrExport": "选择或导出", + "toast": { + "success": "导出成功。进入 /exports 目录查看文件。", + "error": { + "failed": "导出失败:{{error}}", + "endTimeMustAfterStartTime": "结束时间必须在开始时间之后", + "noVaildTimeSelected": "未选择有效的时间范围" + } + }, + "fromTimeline": { + "saveExport": "保存导出", + "previewExport": "预览导出" + } + }, + "streaming": { + "label": "视频流", + "restreaming": { + "NotEnabled": "重新流式传输未启用。", + "desc": "为此摄像头设置 go2rtc,以获取额外的实时预览选项和音频支持。", + "desc.readTheDocumentation": "阅读文档(英文) " + }, + "showStats": { + "label": "显示视频流统计信息", + "desc": "启用后将在摄像头画面上叠加显示视频流统计信息。" + }, + "debugView": "调试界面" + }, + "search": { + "saveSearch": { + "label": "保存搜索", + "desc": "请为此已保存的搜索提供一个名称。", + "placeholder": "请输入搜索名称", + "overwrite": "{{searchName}} 已存在。保存将覆盖现有值。", + "success": "搜索 ({{searchName}}) 已保存。", + "button.save.label": "保存此搜索" + } + }, + "recording": { + "confirmDelete": { + "title": "确认删除", + "desc": "您确定要删除与此审核项相关的所有录制视频吗?

    提示:按住 Shift 键点击删除可跳过此对话框。", + "desc.selected": "您确定要删除与此审核项相关的所有录制视频吗?

    提示:按住 Shift 键点击删除可跳过此对话框。" + }, + "button": { + "export": "导出", + "markAsReviewed": "标记为已审核", + "deleteNow": "立即删除" + } + } +} diff --git a/web/public/locales/zh-CN/components/filter.json b/web/public/locales/zh-CN/components/filter.json new file mode 100644 index 000000000..62ebf5093 --- /dev/null +++ b/web/public/locales/zh-CN/components/filter.json @@ -0,0 +1,108 @@ +{ + "filter": "过滤器", + "labels": { + "label": "标签", + "all": "所有标签", + "all.short": "标签", + "count": "{{count}} 个标签" + }, + "zones": { + "all": "所有区域", + "all.short": "区域" + }, + "dates": { + "all": "所有日期", + "all.short": "日期" + }, + "more": "更多筛选项", + "reset.label": "重置筛选器为默认值", + "timeRange": "时间范围", + "zones.label": "区域", + "subLabels": { + "label": "子标签", + "all": "所有子标签" + }, + "score": "分值", + "estimatedSpeed": "预计速度({{unit}})", + "features": { + "label": "特性", + "hasSnapshot": "包含快照", + "hasVideoClip": "包含视频片段", + "submittedToFrigatePlus": { + "label": "提交至 Frigate+", + "tips": "你必须要先筛选具有快照的探测对象。

    没有快照的跟踪对象无法提交至 Frigate+." + } + }, + "sort": { + "label": "排序", + "dateAsc": "日期 (正序)", + "dateDesc": "日期 (倒序)", + "scoreAsc": "对象分值 (正序)", + "scoreDesc": "对象分值 (倒序)", + "speedAsc": "预计速度 (正序)", + "speedDesc": "预计速度 (倒序)", + "relevance": "关联性" + }, + "cameras": { + "label": "摄像头筛选", + "all": "所有摄像头", + "all.short": "摄像头" + }, + "review": { + "showReviewed": "显示已查看的项目" + }, + "motion": { + "showMotionOnly": "仅显示运动" + }, + "explore": { + "settings": { + "title": "设置", + "defaultView": "默认视图", + "defaultView.desc": "当未选择任何过滤器时,显示每个标签最近跟踪对象的摘要,或显示未过滤的网格。", + "defaultView.summary": "摘要", + "defaultView.unfilteredGrid": "未过滤网格", + "gridColumns": "网格列数", + "gridColumns.desc": "选择网格视图中的列数。", + "searchSource": { + "label": "搜索源", + "desc": "选择是搜索缩略图还是跟踪对象的描述。", + "options": { + "thumbnailImage": "缩略图", + "description": "描述" + } + }, + "date": { + "selectDateBy": { + "label": "选择日期进行筛选" + } + } + } + }, + "logSettings": { + "label": "日志级别筛选", + "filterBySeverity": "按严重程度筛选日志", + "loading": "加载中", + "loading.desc": "当日志面板滚动到底部时,新的日志会自动流式加载。", + "disableLogStreaming": "禁用日志流式加载", + "allLogs": "所有日志" + }, + "trackedObjectDelete": { + "title": "确认删除", + "desc": "删除这 {{objectLength}} 个跟踪对象将移除快照、任何已保存的嵌入和任何相关的对象生命周期条目。历史视图中这些跟踪对象的录制片段将不会被删除。

    您确定要继续吗?

    按住 Shift 键可在将来跳过此对话框。", + "toast": { + "success": "跟踪对象删除成功。", + "error": "删除跟踪对象失败:{{errorMessage}}" + } + }, + "zoneMask": { + "filterBy": "按区域遮罩筛选" + }, + "recognizedLicensePlates": { + "title": "识别的车牌", + "loadFailed": "加载识别的车牌失败。", + "loading": "正在加载识别的车牌...", + "placeholder": "输入以搜索车牌...", + "noLicensePlatesFound": "未找到车牌。", + "selectPlatesFromList": "从列表中选择一个或多个车牌。" + } +} diff --git a/web/public/locales/zh-CN/components/icons.json b/web/public/locales/zh-CN/components/icons.json new file mode 100644 index 000000000..93b486319 --- /dev/null +++ b/web/public/locales/zh-CN/components/icons.json @@ -0,0 +1,8 @@ +{ + "iconPicker": { + "selectIcon": "选择图标", + "search": { + "placeholder": "搜索图标..." + } + } +} \ No newline at end of file diff --git a/web/public/locales/zh-CN/components/input.json b/web/public/locales/zh-CN/components/input.json new file mode 100644 index 000000000..3b4478076 --- /dev/null +++ b/web/public/locales/zh-CN/components/input.json @@ -0,0 +1,10 @@ +{ + "button": { + "downloadVideo": { + "label": "下载视频", + "toast": { + "success": "下载成功" + } + } + } +} \ No newline at end of file diff --git a/web/public/locales/zh-CN/components/player.json b/web/public/locales/zh-CN/components/player.json new file mode 100644 index 000000000..5b70fd856 --- /dev/null +++ b/web/public/locales/zh-CN/components/player.json @@ -0,0 +1,39 @@ +{ + "noRecordingsFoundForThisTime": "找不到此次录制", + "noPreviewFound": "没有找到预览", + "noPreviewFoundFor": "没有在 {{cameraName}} 下找到预览", + "submitFrigatePlus": { + "title": "提交此帧到 Frigate+?", + "submit": "提交" + }, + "livePlayerRequiredIOSVersion": "此直播流类型需要 iOS 17.1 或更高版本。", + "streamOffline": { + "title": "视频流离线", + "desc": "未在 {{cameraName}} 的 detect 流上接收到任何帧,请检查错误日志" + }, + "cameraDisabled": "摄像机已禁用", + "stats": { + "streamType": "流类型:", + "streamType.short": "类型", + "bandwidth": "带宽:", + "bandwidth.short": "带宽", + "latency": "延迟:", + "latency.short": "延迟", + "latency.value": "{{secounds}} 秒", + "latency.short.value": "{{secounds}} 秒", + "totalFrames": "总帧数:", + "droppedFrames": "丢帧数:", + "droppedFrames.short": "丢帧", + "droppedFrames.short.value": "{{droppedFrames}} 帧", + "decodedFrames": "解码帧数:", + "droppedFrameRate": "丢帧率:" + }, + "toast": { + "success": { + "submittedFrigatePlus": "已成功提交帧到 Frigate+" + }, + "error": { + "submitFrigatePlusFailed": "提交帧到 Frigate+ 失败" + } + } +} \ No newline at end of file diff --git a/web/public/locales/zh-CN/objects.json b/web/public/locales/zh-CN/objects.json new file mode 100644 index 000000000..6c0fe7fbd --- /dev/null +++ b/web/public/locales/zh-CN/objects.json @@ -0,0 +1,104 @@ +{ + "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": "手动" +} diff --git a/web/public/locales/zh-CN/views/configEditor.json b/web/public/locales/zh-CN/views/configEditor.json new file mode 100644 index 000000000..9b37414a4 --- /dev/null +++ b/web/public/locales/zh-CN/views/configEditor.json @@ -0,0 +1,15 @@ +{ + "documentTitle": "配置编辑器 - Frigate", + "configEditor": "配置编辑器", + "copyConfig": "复制配置", + "saveAndRestart": "保存并重启", + "saveOnly": "只保存", + "toast": { + "success": { + "copyToClipboard": "配置已复制到剪贴板。" + }, + "error": { + "savingError": "保存配置时出错" + } + } +} \ No newline at end of file diff --git a/web/public/locales/zh-CN/views/events.json b/web/public/locales/zh-CN/views/events.json new file mode 100644 index 000000000..cc12c9838 --- /dev/null +++ b/web/public/locales/zh-CN/views/events.json @@ -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": "摄像头" +} diff --git a/web/public/locales/zh-CN/views/explore.json b/web/public/locales/zh-CN/views/explore.json new file mode 100644 index 000000000..557fe2cea --- /dev/null +++ b/web/public/locales/zh-CN/views/explore.json @@ -0,0 +1,175 @@ +{ + "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": "显示所有区域", + "showAllZones.desc": "在对象进入区域的帧上始终显示区域。", + "offset": { + "label": "标注偏移", + "desc": "这些数据来自摄像头的检测源,但是叠加在录制源的图像上。这两个流不太可能完全同步。因此,边界框和录像不会完全对齐。但是,可以使用 annotation_offset 字段来调整这个问题。", + "documentation": "阅读文档(英文) ", + "millisecondsToOffset": "检测标注的偏移毫秒数。默认值:0", + "tips": "提示:假设有一个人从左向右走的事件片段。如果事件时间线上的边界框始终在人的左侧,则应该减小该值。同样,如果一个人从左向右走,而边界框始终在人的前面,则应该增加该值。" + } + }, + "carousel": { + "previous": "上一张", + "next": "下一张" + } + }, + "details": { + "item": { + "title": "回放项目详情", + "desc": "回放项目详情", + "button": { + "share": "分享该回放", + "viewInExplore": "在探测中查看" + }, + "tips": { + "mismatch_one": "检测到 {{count}} 个不可用的对象,并已包含在此审核项中。这些对象可能未达到警告或检测标准,或者已被清理/删除。", + "mismatch_other": "检测到 {{count}} 个不可用的对象,并已包含在此审核项中。这些对象可能未达到警告或检测标准,或者已被清理/删除。", + "hasMissingObjects": "如果希望 Frigate 保存以下标签的跟踪对象,请调整您的配置:{{objects}}" + }, + "toast": { + "success": { + "regenerate": "已向 {{provider}} 请求新的描述。根据提供商的速度,生成新描述可能需要一些时间。", + "updatedSublabel": "成功更新子标签。" + }, + "error": { + "regenerate": "调用 {{provider}} 生成新描述失败:{{errorMessage}}", + "updatedSublabelFailed": "更新子标签失败:{{errorMessage}}" + } + } + }, + "label": "标签", + "editSubLable": "编辑子标签", + "editSubLable.desc": "为 {{label}} 输入新的子标签", + "editSubLable.desc.noLabel": "为此跟踪对象输入新的子标签", + "topScore": "最高得分", + "topScore.info": "最高分是跟踪对象的最高中位数得分,因此可能与搜索结果缩略图上显示的得分不同。", + "estimatedSpeed": "预计速度", + "objects": "对象", + "camera": "摄像头", + "zones": "区域", + "timestamp": "时间", + "button": { + "findSimilar": "查找相似项" + }, + "description": { + "label": "描述", + "placeholder": "跟踪对象的描述", + "aiTips": "在跟踪对象的生命周期结束之前,Frigate 不会向您的生成式 AI 提供商请求描述。" + }, + "button.regenerate": "重新生成", + "button.regenerate.label": "重新生成跟踪对象描述", + "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": "删除此跟踪对象将移除快照、所有已保存的嵌入数据以及任何关联的对象生命周期条目。但在历史视图中的录制视频不会被删除。

    你确定要继续删除吗?" + } + }, + "noTrackedObjects": "找不到探测的对象", + "fetchingTrackedObjectsFailed": "获取跟踪对象失败:{{errorMessage}}", + "trackedObjectsCount": "{{count}} 个跟踪对象", + "searchResult": { + "deleteTrackedObject": { + "toast": { + "success": "跟踪对象删除成功。", + "error": "删除跟踪对象失败:{{errorMessage}}" + } + } + } +} diff --git a/web/public/locales/zh-CN/views/exports.json b/web/public/locales/zh-CN/views/exports.json new file mode 100644 index 000000000..fe3f849ac --- /dev/null +++ b/web/public/locales/zh-CN/views/exports.json @@ -0,0 +1,17 @@ +{ + "documentTitle": "导出 - Frigate", + "search": "搜索", + "noExports": "没有找到导出的项目", + "deleteExport": "删除导出的项目", + "deleteExport.desc": "你确定要删除 {{exportName}} 吗?", + "editExport": { + "title": "重命名导出", + "desc": "为此导出项目输入新名称。", + "saveExport": "保存导出" + }, + "toast": { + "error": { + "renameExportFailed": "重命名导出失败:{{errorMessage}}" + } + } +} \ No newline at end of file diff --git a/web/public/locales/zh-CN/views/faceLibrary.json b/web/public/locales/zh-CN/views/faceLibrary.json new file mode 100644 index 000000000..49273155e --- /dev/null +++ b/web/public/locales/zh-CN/views/faceLibrary.json @@ -0,0 +1,41 @@ +{ + "documentTitle": "人脸库 - Frigate", + "uploadFaceImage": { + "title": "上传人脸图片", + "desc": "上传图片以扫描人脸并包含在{{pageToggle}}中" + }, + "createFaceLibrary": { + "title": "创建人脸库", + "desc": "创建一个新的人脸库" + }, + "train": { + "title": "训练", + "aria": "选择训练" + }, + "selectItem": "选择{{item}}", + "button": { + "deleteFaceAttempts": "尝试删除人脸", + "addFace": "添加人脸", + "uploadImage": "上传图片", + "reprocessFace:": "重新处理人脸" + }, + "trainFaceAs:": "将人脸训练为:", + "trainFaceAsPerson:": "将人脸训练为人物", + + "toast": { + "success": { + "uploadedImage": "图片上传成功。", + "addFaceLibrary": "人脸库添加成功。", + "deletedFace": "人脸删除成功。", + "trainedFace": "人脸训练成功。", + "updatedFaceScore": "人脸分数更新成功。" + }, + "error": { + "uploadingImageFailed": "图片上传失败:{{errorMessage}}", + "addFaceLibraryFailed": "设置人脸名称失败:{{errorMessage}}", + "deleteFaceFailed": "删除失败:{{errorMessage}}", + "trainFailed": "训练失败:{{errorMessage}}", + "updateFaceScoreFailed": "更新人脸分数失败:{{errorMessage}}" + } + } +} diff --git a/web/public/locales/zh-CN/views/live.json b/web/public/locales/zh-CN/views/live.json new file mode 100644 index 000000000..95b0797b9 --- /dev/null +++ b/web/public/locales/zh-CN/views/live.json @@ -0,0 +1,154 @@ +{ + "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": "音频必须从摄像机输出并在 go2rtc 中配置为此视频流使用。", + "tips.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}} 录制保留配置设置为 mode: {{effectiveRetainMode}},因此此按需录制将仅保留包含 {{effectiveRetainModeName}} 的片段。" + }, + "editLayout": { + "label": "编辑布局", + "group.label": "编辑摄像机分组", + "exitEdit": "退出编辑" +} +} diff --git a/web/public/locales/zh-CN/views/recording.json b/web/public/locales/zh-CN/views/recording.json new file mode 100644 index 000000000..37cab2514 --- /dev/null +++ b/web/public/locales/zh-CN/views/recording.json @@ -0,0 +1,12 @@ +{ + "export": "导出", + "calendar": "日历", + "filter": "筛选", + "filters": "筛选条件", + "toast": { + "error": { + "noValidTimeSelected": "未选择有效的时间范围", + "endTimeMustAfterStartTime": "结束时间必须晚于开始时间" + } + } +} diff --git a/web/public/locales/zh-CN/views/search.json b/web/public/locales/zh-CN/views/search.json new file mode 100644 index 000000000..b33055931 --- /dev/null +++ b/web/public/locales/zh-CN/views/search.json @@ -0,0 +1,65 @@ +{ + "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": "筛选器可帮助您缩小搜索范围。注意,目前还暂不支持中文搜索。以下是在输入字段中使用筛选器的方法:", + "desc.step": "
    • 输入筛选器名称后跟一个冒号(例如:“cameras:”)。
    • 从建议中选择一个值或输入您自己的值。
    • 使用多个筛选器时,可以在它们之间用空格分隔。
    • 日期筛选器(before: 和 after:)使用 {{DateFormat}} 格式。
    • 时间范围筛选器使用 {{exampleTime}} 格式。
    • 点击筛选器旁边的“x”即可移除筛选条件。
    ", + "desc.example": "示例:cameras:front_door label:person before:01012024 time_range:3:00PM-4:00PM" + }, + "header": { + "currentFilterType": "筛选值", + "noFilters": "筛选条件", + "activeFilters": "激活的筛选项" + } + }, + "similaritySearch": { + "title": "相似搜索", + "active": "相似搜索已激活", + "clear": "清除相似搜索" + }, + "placeholder": { + "search": "搜索..." + } +} diff --git a/web/public/locales/zh-CN/views/settings.json b/web/public/locales/zh-CN/views/settings.json new file mode 100644 index 000000000..664796122 --- /dev/null +++ b/web/public/locales/zh-CN/views/settings.json @@ -0,0 +1,433 @@ +{ + "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": "通知" + }, + "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": "每次启动将重新索引并重新处理所有缩略图和描述。关闭该设置后不要忘记重启!" + }, + "modelSize": { + "label": "模型大小", + "desc": "用于语义搜索的语言模型大小", + "small": "小", + "large": "大", + "small.desc": "使用 模型。该模型将使用较少的内存,在CPU上也能较快的运行。质量较好。", + "large.desc": "使用 模型。该模型采用了完整的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 对该摄像头视频流的处理。检测、录制和调试功能都将不可用。
    注意:该选项不会禁用 go2rtc 转播。" + }, + "review": { + "title": "预览", + "desc": "启用/禁用摄像头的警报和检测。禁用后,不会生成新的预览项。", + "alerts": "警告 ", + "detections": "检测 " + }, + "reviewClassification": { + "title": "预览分级", + "desc": "Frigate 将回放项目分为“警告”和“检测”。默认情况下,所有的 汽车 的对象都视为警告。你可以通过修改配置文件配置区域来细分。", + "readTheDocumentation": "阅读文档(英文)", + "noDefinedZones": "该摄像头没有设置区域。", + "objectAlertsTips": "所有的 {{alertsLabels}} 对象在 {{cameraName}} 都将显示为警告。", + "zoneObjectAlertsTips": "所有的 {{alertsLabels}} 对象在 {{cameraName}} 的 {{zone}} 区域都将显示为警告。", + "objectDetectionsTips": "所有未在 {{cameraName}} 归类的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。", + "zoneObjectDetectionsTips": "所有未在 {{cameraName}} 上归类为 {{detectionsLabels}} 的对象在 {{zone}} 区域都将显示为检测。", + "zoneObjectDetectionsTips.notSelectDetections": "所有在 {{cameraName}} 的 {{zone}} 上检测到的未归类为警告的 {{detectionsLabels}} 对象,无论它位于哪个区域,都将显示为检测。", + "zoneObjectDetectionsTips.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": "距离必须大于或等于 0.1。", + "distance.error.mustBeFilled": "所有距离字段必须填写才能使用速度估算。", + "inertia.error.mustBeAboveZero": "惯性必须大于 0。", + "loiteringTime.error.mustBeGreaterOrEqualZero": "徘徊时间必须大于或等于 0。", + "polygonDrawing": { + "removeLastPoint": "删除最后一个点", + "reset.label": "清除所有点", + "snapPoints": { + "true": "启用点对齐", + "false": "禁用点对齐" + }, + "delete": { + "title": "确认删除", + "desc": "你确定要删除{{type}} {{name}} 吗?", + "success": "{{name}} 已被删除。" + }, + "error": { + "mustBeFinished": "多边形绘制必须完成闭合后才能保存。" + } + } + }, + "zones": { + "label": "区域", + "documentTitle": "编辑区域 - Frigate", + "desc": "该功能允许你定义特定区域,以便你可以确定特定对象是否在该区域内。", + "desc.documentation": "文档(英文)", + "add": "添加区域", + "edit": "编辑区域", + "point_one": "{{count}} 点", + "point_other": "{{count}} 点", + "clickDrawPolygon": "在图像上点击添加点绘制多边形区域。", + "name": "区域名称", + "name.inputPlaceHolder": "请输入名称", + "name.tips": "名称至少包含两个字符,且不能和摄像头或其他区域同名。
    当前仅支持英文与数字组合", + "inertia": "惯性", + "inertia.desc": "识别指定对象前该对象必须在这个区域内出现了多少帧。默认值:3", + "loiteringTime": "停留时间", + "loiteringTime.desc": "设置对象必须在区域中活动的最小时间(单位为秒)。默认值:0", + "objects": "对象", + "objects.desc": "将在此区域应用的对象列表。", + "allObjects": "所有对象", + "speedEstimation": "速度估算", + "speedEstimation.desc": "启用此区域内物体的速度估算。该区域必须恰好包含 4 个点。", + "speedThreshold": "速度阈值 ({{unit}})", + "speedThreshold.desc": "指定物体在此区域内被视为有效的最低速度。", + "speedThreshold.toast.error.pointLengthError": "此区域的速度估算已禁用。启用速度估算的区域必须恰好包含 4 个点。", + "speedThreshold.toast.error.loiteringTimeError": "徘徊时间大于 0 的区域不应与速度估算一起使用。", + "toast.success": "区域 ({{zoneName}}) 已保存。请重启 Frigate 以应用更改。" + }, + "motionMasks": { + "label": "运动遮罩", + "documentTitle": "编辑运动遮罩 - Frigate", + "desc": "运动遮罩用于防止触发不必要的运动类型。过度的设置遮罩将使对象更加难以被追踪", + "desc.documentation": "文档(英文)", + "add": "添加运动遮罩", + "edit": "编辑运动遮罩", + "context": "运动遮罩用于防止不需要的运动类型触发检测(例如:树枝、摄像头显示的时间等)。运动遮罩需要谨慎使用,过度的遮罩会导致追踪对象变得更加困难。", + "context.documentation": "阅读文档(英文)", + "point_one": "{{count}} 点", + "point_other": "{{count}} 点", + "clickDrawPolygon": "在图像上点击添加点绘制多边形区域。", + "polygonAreaTooLarge": "运动遮罩的大小达到了摄像头画面的{{polygonArea}}%。不建议设置太大的运动遮罩。", + "polygonAreaTooLarge.tips": "运动遮罩不会阻止检测到对象,你应该使用区域来限制检测对象。", + "polygonAreaTooLarge.documentation": "阅读文档(英文)", + "toast.success": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。", + "toast.success.noName": "运动遮罩已保存。请重启 Frigate 以应用更改。" + }, + "objectMasks": { + "label": "对象遮罩", + "documentTitle": "编辑对象遮罩 - Frigate", + "desc": "对象过滤器用于防止特定位置的指定对象被误报。", + "documentation": "文档(英文)", + "add": "添加对象遮罩", + "edit": "编辑对象遮罩", + "context": "对象过滤器用于防止特定位置的指定对象被误报。", + "point_one": "{{count}} 点", + "point_other": "{{count}} 点", + "clickDrawPolygon": "在图像上点击添加点绘制多边形区域。", + "objects": "对象", + "objects.desc": "将应用于此对象遮罩的对象列表。", + "objects.allObjectTypes": "所有对象类型", + "toast.success": "{{polygonName}} 已保存。请重启 Frigate 以应用更改。", + "toast.success.noName": "对象遮罩已保存。请重启 Frigate 以应用更改。" + } + }, + "motionDetectionTuner": { + "title": "运动检测调整器", + "desc": "Frigate 将使用运动检测作为首个步骤,以确认一帧画面中是否有对象需要使用对象检测。", + "desc.documentation": "阅读有关运动检测的文档(英文)", + "Threshold": "阈值", + "Threshold.desc": "阈值决定像素亮度高于多少时会被认为是运动。默认值:30", + "contourArea": "轮廓面积", + "contourArea.desc": "轮廓面积决定哪些变化的像素组符合运动条件。默认值:10", + "improveContrast": "提高对比度", + "improveContrast.desc": "提高较暗场景的对比度。默认值:开启", + "toast": { + "success": "运动设置已保存。" + } + }, + "debug": { + "title": "调试", + "detectorDesc": "Frigate 将使用探测器({{detectors}})来检测摄像头视频流中的对象。", + "desc": "调试界面将实时显示被追踪的对象以及统计信息,对象列表将显示检测到的对象和延迟显示的概览。", + "debugging": "调试选项", + "objectList": "对象列表", + "noObjects": "没有对象", + "boundingBoxes": { + "title": "边界框", + "desc": "将在被追踪的对象周围显示边界框", + "colors": { + "label": "对象边界框颜色定义", + "info": "
  • 启用后,将会为每个对象标签分配不同的颜色
  • 深蓝色细线代表该对象在当前时间点未被检测到
  • 灰色细线代表检测到的物体静止不动
  • 粗线表示该对象为自动跟踪的主体(在启动时)
  • " + } + }, + "timestamp": { + "title": "时间戳", + "desc": "在图像上显示时间戳" + }, + "zones": { + "title": "区域", + "desc": "显示已定义的区域图层" + }, + "mask": { + "title": "运动遮罩", + "desc": "显示运动遮罩图层" + }, + "motion": { + "title": "运动区域框", + "desc": "在检测到运动的区域显示区域框", + "tips": "

    运动区域框


    将在当前检测到运动的区域内显示红色区域框。

    " + }, + "regions": { + "title": "范围", + "desc": "显示发送到运动检测器感兴趣范围的框。", + "tips": "

    范围框


    将在帧中发送到目标检测器的感兴趣范围上叠加绿色框。

    " + }, + "objectShapeFilterDrawing": { + "title": "允许绘制“对象形状过滤器”", + "desc": "在图像上绘制矩形,以查看区域和比例详细信息。", + "tips": "启用此选项,能够在摄像头图像上绘制矩形,将显示其区域和比例。然后,您可以使用这些值在配置中设置对象形状过滤器参数。", + "document": "阅读文档(英文)", + "score": "分数", + "ratio": "比例", + "area": "区域" + } + }, + "users": { + "title": "用户", + "management": "用户管理", + "management.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": "用户名", + "user.desc": "仅允许使用字母、数字、句点和下划线。", + "user.placeholder": "请输入用户名", + "password": "密码", + "password.placeholder": "请输入密码", + "password.confirm": "确认密码", + "password.confirm.placeholder": "请再次输入密码", + "password.strength": "密码强度:", + "password.strength.weak": "弱", + "password.strength.medium": "中等", + "password.strength.strong": "强", + "password.strength.veryStrong": "非常强", + "password.match": "密码匹配", + "password.notMatch": "密码不匹配", + "newPassword": "新密码", + "newPassword.placeholder": "请输入新密码", + "newPassword.confirm.placeholder": "请再次输入新密码", + "usernameIsRequired": "用户名为必填项" + }, + "createUser": { + "title": "创建新用户", + "desc": "创建一个新用户账户,并指定一个角色以控制访问 Frigate UI 的权限。", + "user": "用户", + "password": "密码", + "usernameOnlyInclude": "用户名只能包含字母、数字和 _" + }, + "deleteUser": { + "title": "删除该用户", + "desc": "此操作无法撤销。这将永久删除用户账户并移除所有相关数据。", + "warn": "你确定要删除 {{username}} 吗?" + }, + "passwordSetting": { + "updatePassword": "更新 {{username}} 的密码", + "setPassword": "设置密码", + "desc": "创建一个强密码来保护此账户。" + }, + "changeRole": { + "title": "更改用户权限组", + "desc": "更新 {{username}} 的权限", + "roleInfo": "

    请选择此用户的适当角色:

    • 管理员 (Admin): 拥有所有功能的完整访问权限。
    • 查看者 (Viewer): 仅限访问实时监控、回放、探测和导出功能。
    " + } + } + }, + "notification": { + "title": "通知", + "notificationSettings": { + "title": "通知设置", + "desc": "Frigate 在浏览器中运行或作为 PWA 安装时,可以原生向您的设备发送推送通知。", + "documentation": "阅读文档(英文)" + }, + "globalSettings": { + "title": "全局设置", + "desc": "临时暂停所有已注册设备上特定摄像头的通知。" + }, + "notificationUnavailable": { + "title": "通知功能不可用", + "desc": "网页推送通知需要安全连接(https://...)。这是浏览器的限制。请通过安全方式访问 Frigate 以使用通知功能。", + "documentation": "阅读文档(英文)" + }, + "email": "电子邮箱", + "email.placeholder": "例如:example@email.com", + "email.desc": "需要输入有效的电子邮件,在推送服务出现问题时,将使用此电子邮件进行通知。", + "cameras": "摄像头", + "cameras.noCameras": "没有可用的摄像头", + "cameras.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": "通知注册失败。" + } + } + } +} diff --git a/web/public/locales/zh-CN/views/system.json b/web/public/locales/zh-CN/views/system.json new file mode 100644 index 000000000..548818ac2 --- /dev/null +++ b/web/public/locales/zh-CN/views/system.json @@ -0,0 +1,144 @@ +{ + "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": "摄像头", + "unused": "未使用", + "unusedStorageInformation": "未使用存储信息", + "storageUsed": "存储使用", + "percentageOfTotalUsed": "总使用率", + "bandwidth": "带宽", + "unused.tips": "如果您的驱动器上存储了除 Frigate 录制内容之外的其他文件,该值可能无法准确反映 Frigate 可用的剩余空间。Frigate 不会追踪录制内容以外的存储使用情况。" + } + }, + "cameras": { + "title": "摄像头", + "overview": "概览", + "info": { + "cameraProbeInfo": "{{camera}} 的摄像头信息", + "streamDataFromFFPROBE": "流数据信息通过ffprobe获取。", + "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": "功能" + } +} diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx index 1b20b26f6..2b03b750d 100644 --- a/web/src/components/Statusbar.tsx +++ b/web/src/components/Statusbar.tsx @@ -5,12 +5,16 @@ import { } from "@/context/statusbar-provider"; import useStats, { useAutoFrigateStats } from "@/hooks/use-stats"; import { useContext, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + import { FaCheck } from "react-icons/fa"; import { IoIosWarning } from "react-icons/io"; import { MdCircle } from "react-icons/md"; import { Link } from "react-router-dom"; export default function Statusbar() { + const { t } = useTranslation(["views/system"]); + const { messages, addMessage, clearMessages } = useContext( StatusBarMessagesContext, )!; @@ -50,14 +54,19 @@ export default function Statusbar() { clearMessages("embeddings-reindex"); addMessage( "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") { clearMessages("embeddings-reindex"); } } - }, [reindexState, addMessage, clearMessages]); + }, [reindexState, addMessage, clearMessages, t]); return (
    @@ -129,7 +138,7 @@ export default function Statusbar() { {Object.entries(messages).length === 0 ? (
    - System is healthy + {t("stats.healthy")}
    ) : ( Object.entries(messages).map(([key, messageArray]) => ( diff --git a/web/src/components/auth/AuthForm.tsx b/web/src/components/auth/AuthForm.tsx index 85bd6bccb..a90696dd7 100644 --- a/web/src/components/auth/AuthForm.tsx +++ b/web/src/components/auth/AuthForm.tsx @@ -21,16 +21,18 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { AuthContext } from "@/context/auth-context"; +import { useTranslation } from "react-i18next"; interface UserAuthFormProps extends React.HTMLAttributes {} export function UserAuthForm({ className, ...props }: UserAuthFormProps) { + const { t } = useTranslation(["components/auth"]); const [isLoading, setIsLoading] = React.useState(false); const { login } = React.useContext(AuthContext); const formSchema = z.object({ - user: z.string().min(1, "Username is required"), - password: z.string().min(1, "Password is required"), + user: z.string().min(1, t("form.errors.usernameRequired")), + password: z.string().min(1, t("form.errors.passwordRequired")), }); const form = useForm>({ @@ -62,20 +64,20 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { if (axios.isAxiosError(error)) { const err = error as AxiosError; if (err.response?.status === 429) { - toast.error("Exceeded rate limit. Try again later.", { + toast.error(t("form.errors.rateLimit"), { position: "top-center", }); } else if (err.response?.status === 401) { - toast.error("Login failed", { + toast.error(t("form.errors.loginFailed"), { position: "top-center", }); } else { - toast.error("Unknown error. Check logs.", { + toast.error(t("form.errors.unknownError"), { position: "top-center", }); } } else { - toast.error("Unknown error. Check console logs.", { + toast.error(t("form.errors.webUnkownError"), { position: "top-center", }); } @@ -92,7 +94,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) { name="user" render={({ field }) => ( - User + {t("form.user")} ( - Password + {t("form.password")} {isLoading && } - Login + {t("form.login")}
    diff --git a/web/src/components/button/DownloadVideoButton.tsx b/web/src/components/button/DownloadVideoButton.tsx index 750b35607..094cf9308 100644 --- a/web/src/components/button/DownloadVideoButton.tsx +++ b/web/src/components/button/DownloadVideoButton.tsx @@ -3,6 +3,7 @@ import { toast } from "sonner"; import { FaDownload } from "react-icons/fa"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; type DownloadVideoButtonProps = { source: string; @@ -17,6 +18,7 @@ export function DownloadVideoButton({ startTime, className, }: DownloadVideoButtonProps) { + const { t } = useTranslation(["components/input"]); const formattedDate = formatUnixTimestampToDateTime(startTime, { strftime_fmt: "%D-%T", time_style: "medium", @@ -25,7 +27,7 @@ export function DownloadVideoButton({ const filename = `${camera}_${formattedDate}.mp4`; const handleDownloadStart = () => { - toast.success("Your review item video has started downloading.", { + toast.success(t("button.downloadVideo.toast.success"), { position: "top-center", }); }; @@ -36,7 +38,7 @@ export function DownloadVideoButton({ asChild className="flex items-center gap-2" size="sm" - aria-label="Download Video" + aria-label={t("button.downloadVideo.label")} > ( `${cameraConfig?.name}-feed`, @@ -59,17 +61,21 @@ export default function DebugCameraImage({ onClick={handleToggleSettings} variant="link" size="sm" - aria-label="Settings" + aria-label={t("debug.options.label")} > {" "} - {showSettings ? "Hide" : "Show"} Options + + {showSettings + ? t("debug.options.hideOptions") + : t("debug.options.showOptions")} + {showSettings ? ( - Options + {t("debug.options.title")}
    @@ -99,7 +106,7 @@ function DebugSettings({ handleSetOption, options }: DebugSettingsProps) { handleSetOption("bbox", isChecked); }} /> - +
    - +
    - +
    - +
    - +
    - +
    ); diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index a41e2252a..1fc37b36f 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -18,6 +18,7 @@ import { Skeleton } from "../ui/skeleton"; import { Button } from "../ui/button"; import { FaCircleCheck } from "react-icons/fa6"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; type AnimatedEventCardProps = { event: ReviewSegment; @@ -29,6 +30,7 @@ export function AnimatedEventCard({ selectedGroup, updateEvents, }: AnimatedEventCardProps) { + const { t } = useTranslation(["views/events"]); const { data: config } = useSWR("config"); const apiHost = useApiHost(); @@ -121,7 +123,7 @@ export function AnimatedEventCard({ - Mark as Reviewed + {t("markAsReviewed")} )} {previews != undefined && ( diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index c47532df8..fcd970904 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -20,6 +20,7 @@ import { MdEditSquare } from "react-icons/md"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { shareOrCopy } from "@/utils/browserUtil"; +import { useTranslation } from "react-i18next"; type ExportProps = { className: string; @@ -36,6 +37,7 @@ export default function ExportCard({ onRename, onDelete, }: ExportProps) { + const { t } = useTranslation(["views/exports"]); const [hovered, setHovered] = useState(false); const [loading, setLoading] = useState( exportedRecording.thumb_path.length > 0, @@ -89,10 +91,8 @@ export default function ExportCard({ } }} > - Rename Export - - Enter a new name for this export. - + {t("editExport.title")} + {t("editExport.desc")} {editName && ( <> @@ -207,7 +207,7 @@ export default function ExportCard({ {!exportedRecording.in_progress && (
    {!event.has_been_reviewed && ( @@ -257,7 +255,9 @@ export default function ReviewCard({ onClick={onMarkAsReviewed} > -
    Mark as reviewed
    +
    + {t("recording.button.markAsReviewed")} +
    )} @@ -268,7 +268,9 @@ export default function ReviewCard({ >
    - {bypassDialogRef.current ? "Delete Now" : "Delete"} + {bypassDialogRef.current + ? t("recording.button.deleteNow") + : t("button.delete", { ns: "common" })}
    @@ -286,24 +288,22 @@ export default function ReviewCard({ > - Confirm Delete + + {t("recording.confirmDelete.title")} + - Are you sure you want to delete all recorded video associated with - this review item? -
    -
    - Hold the Shift key to bypass this dialog in the future. + recording.confirmDelete.desc
    setOptionsOpen(false)}> - Cancel + {t("button.cancel", { ns: "common" })} - Delete + {t("button.delete", { ns: "common" })}
    @@ -316,7 +316,7 @@ export default function ReviewCard({ onClick={onExport} > -
    Export
    +
    {t("recording.button.export")}
    {!event.has_been_reviewed && (
    -
    Mark as reviewed
    +
    + {t("recording.button.markAsReviewed")} +
    )}
    - {bypassDialogRef.current ? "Delete Now" : "Delete"} + {bypassDialogRef.current + ? t("recording.button.deleteNow") + : t("button.delete", { ns: "common" })}
    diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index ed98e86b4..b41533f7b 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -8,11 +8,11 @@ import Chip from "@/components/indicators/Chip"; import useImageLoaded from "@/hooks/use-image-loaded"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; -import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { SearchResult } from "@/types/search"; import { cn } from "@/lib/utils"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import useContextMenu from "@/hooks/use-contextmenu"; +import { useTranslation } from "react-i18next"; type SearchThumbnailProps = { searchResult: SearchResult; @@ -23,6 +23,7 @@ export default function SearchThumbnail({ searchResult, onClick, }: SearchThumbnailProps) { + const { t } = useTranslation(["views/search"]); const apiHost = useApiHost(); const { data: config } = useSWR("config"); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); @@ -113,7 +114,7 @@ export default function SearchThumbnail({ .filter( (item) => item !== undefined && !item.includes("-verified"), ) - .map((text) => capitalizeFirstLetter(text)) + .map((text) => t(text, { ns: "objects" })) .sort() .join(", ") .replaceAll("-verified", "")} diff --git a/web/src/components/card/SearchThumbnailFooter.tsx b/web/src/components/card/SearchThumbnailFooter.tsx index 33db0c598..a24dfdd70 100644 --- a/web/src/components/card/SearchThumbnailFooter.tsx +++ b/web/src/components/card/SearchThumbnailFooter.tsx @@ -6,6 +6,7 @@ import { SearchResult } from "@/types/search"; import ActivityIndicator from "../indicators/activity-indicator"; import SearchResultActions from "../menu/SearchResultActions"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; type SearchThumbnailProps = { searchResult: SearchResult; @@ -24,12 +25,15 @@ export default function SearchThumbnailFooter({ showObjectLifecycle, showSnapshot, }: SearchThumbnailProps) { + const { t } = useTranslation(["views/search"]); const { data: config } = useSWR("config"); // date const formattedDate = useFormattedTimestamp( 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", { ns: "common" }), config?.ui.timezone, ); diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx index 473f187ed..b0fd747b7 100644 --- a/web/src/components/dynamic/NewReviewData.tsx +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -3,6 +3,7 @@ import { Button } from "../ui/button"; import { LuRefreshCcw } from "react-icons/lu"; import { MutableRefObject, useMemo } from "react"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; type NewReviewDataProps = { className: string; @@ -18,6 +19,7 @@ export default function NewReviewData({ itemsToReview, pullLatestData, }: NewReviewDataProps) { + const { t } = useTranslation(["views/events"]); const hasUpdate = useMemo(() => { if (!reviewItems || !itemsToReview) { return false; @@ -36,7 +38,7 @@ export default function NewReviewData({ : "invisible", "mx-auto bg-gray-400 text-center text-white", )} - aria-label="View new review items" + aria-label={t("newReviewItems.label")} onClick={() => { pullLatestData(); if (contentRef.current) { @@ -48,7 +50,7 @@ export default function NewReviewData({ }} > - New Items To Review + {t("newReviewItems.button")}
    diff --git a/web/src/components/dynamic/TimeAgo.tsx b/web/src/components/dynamic/TimeAgo.tsx index 13892180e..594187988 100644 --- a/web/src/components/dynamic/TimeAgo.tsx +++ b/web/src/components/dynamic/TimeAgo.tsx @@ -1,3 +1,4 @@ +import { t } from "i18next"; import { FunctionComponent, useEffect, useMemo, useState } from "react"; interface IProp { @@ -40,7 +41,7 @@ const timeAgo = ({ const elapsed: number = elapsedTime / 1000; if (elapsed < 10) { - return "just now"; + return t("time.justNow"); } for (let i = 0; i < timeUnits.length; i++) { @@ -64,11 +65,19 @@ const timeAgo = ({ if (monthDiff > 0) { const unitAmount = monthDiff; - return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? "" : "s"} ago`; + return t("time.ago", { + timeAgo: t(`time.${dense ? timeUnits[i].unit : timeUnits[i].full}`, { + time: unitAmount, + }), + }); } } else if (elapsed >= timeUnits[i].value) { const unitAmount: number = Math.floor(elapsed / timeUnits[i].value); - return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? "" : "s"} ago`; + return t("time.ago", { + timeAgo: t(`time.${dense ? timeUnits[i].unit : timeUnits[i].full}`, { + time: unitAmount, + }), + }); } } return "Invalid Time"; diff --git a/web/src/components/filter/CalendarFilterButton.tsx b/web/src/components/filter/CalendarFilterButton.tsx index afa70b4e5..524c2a2e6 100644 --- a/web/src/components/filter/CalendarFilterButton.tsx +++ b/web/src/components/filter/CalendarFilterButton.tsx @@ -14,6 +14,7 @@ import { DateRangePicker } from "../ui/calendar-range"; import { DateRange } from "react-day-picker"; import { useState } from "react"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; +import { useTranslation } from "react-i18next"; type CalendarFilterButtonProps = { reviewSummary?: ReviewSummary; @@ -27,16 +28,17 @@ export default function CalendarFilterButton({ day, updateSelectedDay, }: CalendarFilterButtonProps) { + const { t } = useTranslation(["components/filter"]); const [open, setOpen] = useState(false); const selectedDate = useFormattedTimestamp( day == undefined ? 0 : day?.getTime() / 1000 + 1, - "%b %-d", + t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }), ); const trigger = ( ); @@ -61,12 +65,12 @@ export default function CalendarFilterButton({
    @@ -93,18 +97,19 @@ export function CalendarRangeFilterButton({ defaultText, updateSelectedRange, }: CalendarRangeFilterButtonProps) { + const { t } = useTranslation(["components/filter"]); const [open, setOpen] = useState(false); const selectedDate = useFormattedRange( range?.from == undefined ? 0 : range.from.getTime() / 1000 + 1, range?.to == undefined ? 0 : range.to.getTime() / 1000 - 1, - "%b %-d", + t("time.formattedTimestampOnlyMonthAndDay", { ns: "common" }), ); const trigger = ( - - All Cameras + + {t("menu.live.allCameras", { ns: "common" })}
    @@ -175,7 +179,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { ? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60" : "bg-secondary text-secondary-foreground" } - aria-label="Camera Group" + aria-label={t("group.label")} size="xs" onClick={() => setGroup(name, group != "default")} onMouseEnter={() => (isDesktop ? showTooltip(name) : null)} @@ -202,7 +206,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
    diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx index d9deb87bd..d8277b5f8 100644 --- a/web/src/components/filter/CamerasFilterButton.tsx +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -12,6 +12,7 @@ import { isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import FilterSwitch from "./FilterSwitch"; import { FaVideo } from "react-icons/fa"; +import { useTranslation } from "react-i18next"; type CameraFilterButtonProps = { allCameras: string[]; @@ -29,6 +30,7 @@ export function CamerasFilterButton({ mainCamera, updateCameraFilter, }: CameraFilterButtonProps) { + const { t } = useTranslation(["components/filter"]); const [open, setOpen] = useState(false); const [currentCameras, setCurrentCameras] = useState( selectedCameras, @@ -36,15 +38,19 @@ export function CamerasFilterButton({ const buttonText = useMemo(() => { if (isMobile) { - return "Cameras"; + return t("menu.live.cameras", { ns: "common" }); } if (!selectedCameras || selectedCameras.length == 0) { - return "All Cameras"; + return t("menu.live.allCameras", { ns: "common" }); } - - return `${selectedCameras.includes("birdseye") ? selectedCameras.length - 1 : selectedCameras.length} Camera${selectedCameras.length !== 1 ? "s" : ""}`; - }, [selectedCameras]); + return t("menu.live.cameras.count", { + count: selectedCameras.includes("birdseye") + ? selectedCameras.length - 1 + : selectedCameras.length, + ns: "common", + }); + }, [selectedCameras, t]); // ui @@ -57,7 +63,7 @@ export function CamerasFilterButton({ const trigger = (
    diff --git a/web/src/components/filter/LogSettingsButton.tsx b/web/src/components/filter/LogSettingsButton.tsx index e9465bf1d..56c440352 100644 --- a/web/src/components/filter/LogSettingsButton.tsx +++ b/web/src/components/filter/LogSettingsButton.tsx @@ -9,6 +9,7 @@ import { Switch } from "../ui/switch"; import { DropdownMenuSeparator } from "../ui/dropdown-menu"; import { cn } from "@/lib/utils"; import FilterSwitch from "./FilterSwitch"; +import { useTranslation } from "react-i18next"; type LogSettingsButtonProps = { selectedLabels?: LogSeverity[]; @@ -22,23 +23,26 @@ export function LogSettingsButton({ logSettings, setLogSettings, }: LogSettingsButtonProps) { + const { t } = useTranslation(["components/filter"]); const trigger = ( ); const content = (
    -
    Filter
    +
    {t("filter")}
    - Filter logs by severity. + {t("logSettings.filterBySeverity")}
    -
    Loading
    +
    {t("logSettings.loading")}
    - When the log pane is scrolled to the bottom, new logs - automatically stream as they are added. + {t("logSettings.loading.desc")}
    { setLogSettings({ @@ -97,6 +100,7 @@ export function GeneralFilterContent({ selectedLabels, updateLabelFilter, }: GeneralFilterContentProps) { + const { t } = useTranslation(["components/filter"]); return ( <>
    @@ -105,7 +109,7 @@ export function GeneralFilterContent({ className="mx-2 cursor-pointer text-primary" htmlFor="allLabels" > - All Logs + {t("logSettings.allLogs")} { setSelectedReviews([]); }, [setSelectedReviews]); @@ -68,22 +70,24 @@ export default function ReviewActionGroup({ > - Confirm Delete + + {t("recording.confirmDelete.title")} + - Are you sure you want to delete all recorded video associated with - the selected review items? -
    -
    - Hold the Shift key to bypass this dialog in the future. + + recording.confirmDelete.desc.selected +
    - Cancel + + {t("button.cancel", { ns: "common" })} + - Delete + {t("button.delete", { ns: "common" })}
    @@ -97,14 +101,14 @@ export default function ReviewActionGroup({ className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary" onClick={onClearSelected} > - Unselect + {t("button.unselect", { ns: "common" })}
    {selectedReviews.length == 1 && ( )} diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 09eb8092a..fe2b4034b 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -23,6 +23,7 @@ import { FilterList, GeneralFilter } from "@/types/filter"; import CalendarFilterButton from "./CalendarFilterButton"; import { CamerasFilterButton } from "./CamerasFilterButton"; import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog"; +import { useTranslation } from "react-i18next"; const REVIEW_FILTERS = [ "cameras", @@ -263,6 +264,7 @@ function ShowReviewFilter({ showReviewed, setShowReviewed, }: ShowReviewedFilterProps) { + const { t } = useTranslation(["components/filter"]); const [showReviewedSwitch, setShowReviewedSwitch] = useOptimisticState( showReviewed, setShowReviewed, @@ -278,13 +280,13 @@ function ShowReviewFilter({ } />
    ); @@ -439,13 +442,14 @@ export function GeneralFilterContent({ onReset, onClose, }: GeneralFilterContentProps) { + const { t } = useTranslation(["components/filter"]); return ( <>
    {currentSeverity && (
    - All Labels + {t("labels.all")} - All Zones + {t("zones.all")}
    -
    @@ -593,6 +600,7 @@ function ShowMotionOnlyButton({ motionOnly, setMotionOnly, }: ShowMotionOnlyButtonProps) { + const { t } = useTranslation(["views/events"]); const [motionOnlyButton, setMotionOnlyButton] = useOptimisticState( motionOnly, setMotionOnly, @@ -611,7 +619,7 @@ function ShowMotionOnlyButton({ className="mx-2 cursor-pointer text-primary" htmlFor="collapse-motion" > - Motion only + {t("motion.only")}
    @@ -619,7 +627,7 @@ function ShowMotionOnlyButton({
    diff --git a/web/src/components/filter/SearchFilterGroup.tsx b/web/src/components/filter/SearchFilterGroup.tsx index 740a3bce7..454ab71d7 100644 --- a/web/src/components/filter/SearchFilterGroup.tsx +++ b/web/src/components/filter/SearchFilterGroup.tsx @@ -25,6 +25,8 @@ import SearchFilterDialog from "../overlay/dialog/SearchFilterDialog"; import { CalendarRangeFilterButton } from "./CalendarFilterButton"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { useTranslation } from "react-i18next"; + type SearchFilterGroupProps = { className: string; filters?: SearchFilters[]; @@ -39,6 +41,7 @@ export default function SearchFilterGroup({ filterList, onUpdateFilter, }: SearchFilterGroupProps) { + const { t } = useTranslation(["components/filter"]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); @@ -195,7 +198,7 @@ export default function SearchFilterGroup({ to: new Date(filter.before * 1000), } } - defaultText={isMobile ? "Dates" : "All Dates"} + defaultText={isMobile ? t("dates.all.short") : t("dates.all")} updateSelectedRange={onUpdateSelectedRange} /> )} @@ -229,6 +232,7 @@ function GeneralFilterButton({ selectedLabels, updateLabelFilter, }: GeneralFilterButtonProps) { + const { t } = useTranslation(["components/filter"]); const [open, setOpen] = useState(false); const [currentLabels, setCurrentLabels] = useState( selectedLabels, @@ -236,19 +240,21 @@ function GeneralFilterButton({ const buttonText = useMemo(() => { if (isMobile) { - return "Labels"; + return t("labels.all.short"); } if (!selectedLabels || selectedLabels.length == 0) { - return "All Labels"; + return t("labels.all"); } if (selectedLabels.length == 1) { - return selectedLabels[0]; + return t(selectedLabels[0], { ns: "objects" }); } - return `${selectedLabels.length} Labels`; - }, [selectedLabels]); + return t("labels.count", { + count: selectedLabels.length, + }); + }, [selectedLabels, t]); // ui @@ -263,7 +269,7 @@ function GeneralFilterButton({ size="sm" variant={selectedLabels?.length ? "select" : "default"} className="flex items-center gap-2 capitalize" - aria-label="Labels" + aria-label={t("labels.label")} >
    @@ -331,7 +338,7 @@ export function GeneralFilterContent({ className="mx-2 cursor-pointer text-primary" htmlFor="allLabels" > - All Labels + {t("labels.all")} ( { if (isChecked) { @@ -373,7 +380,7 @@ export function GeneralFilterContent({
    @@ -411,6 +418,7 @@ function SortTypeButton({ selectedSortType, updateSortType, }: SortTypeButtonProps) { + const { t } = useTranslation(["components/filter"]); const [open, setOpen] = useState(false); const [currentSortType, setCurrentSortType] = useState< SearchSortType | undefined @@ -433,7 +441,7 @@ function SortTypeButton({ : "default" } className="flex items-center gap-2 capitalize" - aria-label="Labels" + aria-label={t("labels.label")} > - Sort + {t("sort.label")}
    ); @@ -496,16 +504,16 @@ export function SortTypeContent({ setCurrentSortType, onClose, }: SortTypeContentProps) { + const { t } = useTranslation(["components/filter"]); const sortLabels = { - date_asc: "Date (Ascending)", - date_desc: "Date (Descending)", - score_asc: "Object Score (Ascending)", - score_desc: "Object Score (Descending)", - speed_asc: "Estimated Speed (Ascending)", - speed_desc: "Estimated Speed (Descending)", - relevance: "Relevance", + date_asc: t("sort.dateAsc"), + date_desc: t("sort.dateDesc"), + score_asc: t("sort.scoreAsc"), + score_desc: t("sort.scoreDesc"), + speed_asc: t("sort.speedAsc"), + speed_desc: t("sort.speedDesc"), + relevance: t("sort.relevance"), }; - return ( <>
    @@ -548,7 +556,7 @@ export function SortTypeContent({
    diff --git a/web/src/components/filter/ZoneMaskFilter.tsx b/web/src/components/filter/ZoneMaskFilter.tsx index 28d86b499..5fd59cd76 100644 --- a/web/src/components/filter/ZoneMaskFilter.tsx +++ b/web/src/components/filter/ZoneMaskFilter.tsx @@ -7,6 +7,7 @@ import { PolygonType } from "@/types/canvas"; import { Label } from "../ui/label"; import { Switch } from "../ui/switch"; import { DropdownMenuSeparator } from "../ui/dropdown-menu"; +import { useTranslation } from "react-i18next"; type ZoneMaskFilterButtonProps = { selectedZoneMask?: PolygonType[]; @@ -16,12 +17,13 @@ export function ZoneMaskFilterButton({ selectedZoneMask, updateZoneMaskFilter, }: ZoneMaskFilterButtonProps) { + const { t } = useTranslation(["components/filter"]); const trigger = (
    ); @@ -67,6 +69,7 @@ export function GeneralFilterContent({ selectedZoneMask, updateZoneMaskFilter, }: GeneralFilterContentProps) { + const { t } = useTranslation(["components/filter"]); return ( <>
    @@ -75,7 +78,7 @@ export function GeneralFilterContent({ className="mx-2 cursor-pointer text-primary" htmlFor="allLabels" > - All Masks and Zones + {t("labels.all")} - {item - .replace(/_/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()) + "s"} + {t( + "masksAndZones." + + item + .replace(/_([a-z])/g, (letter) => letter.toUpperCase()) + .replace("_", "") + + "s.label", + { ns: "views/settings" }, + )} ("config", { revalidateOnFocus: false, }); @@ -126,7 +128,9 @@ export function CameraLineGraph({ className="size-2" style={{ color: GRAPH_COLORS[labelIdx] }} /> -
    {label}
    +
    + {t("cameras.label." + label)} +
    {lastValues[labelIdx]} {unit} diff --git a/web/src/components/graph/CombinedStorageGraph.tsx b/web/src/components/graph/CombinedStorageGraph.tsx index 7f10f0ab5..5e988b52b 100644 --- a/web/src/components/graph/CombinedStorageGraph.tsx +++ b/web/src/components/graph/CombinedStorageGraph.tsx @@ -16,7 +16,9 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { getUnitSize } from "@/utils/storageUtil"; + import { CiCircleAlert } from "react-icons/ci"; +import { useTranslation } from "react-i18next"; type CameraStorage = { [key: string]: { @@ -41,6 +43,8 @@ export function CombinedStorageGraph({ cameraStorage, totalStorage, }: CombinedStorageGraphProps) { + const { t } = useTranslation(["views/system"]); + const { theme, systemTheme } = useTheme(); const entities = Object.keys(cameraStorage); @@ -176,10 +180,12 @@ export function CombinedStorageGraph({ - Camera - Storage Used - Percentage of Total Used - Bandwidth + {t("storage.cameraStorage.camera")} + {t("storage.cameraStorage.storageUsed")} + + {t("storage.cameraStorage.percentageOfTotalUsed")} + + {t("storage.cameraStorage.bandwidth")} @@ -191,26 +197,29 @@ export function CombinedStorageGraph({ className="size-3 rounded-md" style={{ backgroundColor: item.color }} > - {item.name.replaceAll("_", " ")} + {item.name === "Unused" + ? t("storage.cameraStorage.unused") + : item.name.replaceAll("_", " ")} {item.name === "Unused" && (
    - 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. + {t("storage.cameraStorage.unused.tips")}
    diff --git a/web/src/components/icons/IconPicker.tsx b/web/src/components/icons/IconPicker.tsx index d58b57ead..b74029e0b 100644 --- a/web/src/components/icons/IconPicker.tsx +++ b/web/src/components/icons/IconPicker.tsx @@ -12,6 +12,8 @@ import Heading from "../ui/heading"; import { cn } from "@/lib/utils"; import { Button } from "../ui/button"; +import { useTranslation } from "react-i18next"; + export type IconName = keyof typeof LuIcons; export type IconElement = { @@ -30,6 +32,7 @@ export default function IconPicker({ selectedIcon, setSelectedIcon, }: IconPickerProps) { + const { t } = useTranslation(["components/icons"]); const [open, setOpen] = useState(false); const containerRef = useRef(null); const [searchTerm, setSearchTerm] = useState(""); @@ -68,9 +71,9 @@ export default function IconPicker({ {!selectedIcon?.name || !selectedIcon?.Icon ? ( ) : (
    @@ -101,7 +104,7 @@ export default function IconPicker({ className="flex max-h-[50dvh] flex-col overflow-y-hidden md:max-h-[30dvh]" >
    - Select an icon + {t("iconPicker.selectIcon")} setSearchTerm(e.target.value)} diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx index 3ae78e70a..1f9f17626 100644 --- a/web/src/components/input/InputWithTags.tsx +++ b/web/src/components/input/InputWithTags.tsx @@ -51,6 +51,7 @@ import { toast } from "sonner"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { MdImageSearch } from "react-icons/md"; +import { Trans, useTranslation } from "react-i18next"; type InputWithTagsProps = { inputFocused: boolean; @@ -73,6 +74,7 @@ export default function InputWithTags({ setSearch, allSuggestions, }: InputWithTagsProps) { + const { t } = useTranslation(["views/search"]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); @@ -236,12 +238,9 @@ export default function InputWithTags({ filters.after && timestamp <= filters.after * 1000 ) { - toast.error( - "The 'before' date must be later than the 'after' date.", - { - position: "top-center", - }, - ); + toast.error(t("filter.toast.error.beforeDateBeLaterAfter"), { + position: "top-center", + }); return; } if ( @@ -249,12 +248,9 @@ export default function InputWithTags({ filters.before && timestamp >= filters.before * 1000 ) { - toast.error( - "The 'after' date must be earlier than the 'before' date.", - { - position: "top-center", - }, - ); + toast.error(t("afterDatebeEarlierBefore"), { + position: "top-center", + }); return; } if (type === "before") { @@ -274,7 +270,7 @@ export default function InputWithTags({ score > filters.max_score * 100 ) { toast.error( - "The 'min_score' must be less than or equal to the 'max_score'.", + t("filter.toast.error.minScoreMustBeLessOrEqualMaxScore"), { position: "top-center", }, @@ -287,7 +283,7 @@ export default function InputWithTags({ score < filters.min_score * 100 ) { toast.error( - "The 'max_score' must be greater than or equal to the 'min_score'.", + t("filter.toast.error.maxScoreMustBeGreaterOrEqualMinScore"), { position: "top-center", }, @@ -308,7 +304,7 @@ export default function InputWithTags({ speed > filters.max_speed ) { toast.error( - "The 'min_speed' must be less than or equal to the 'max_speed'.", + t("filter.toast.error.minSpeedMustBeLessOrEqualMaxSpeed"), { position: "top-center", }, @@ -321,7 +317,7 @@ export default function InputWithTags({ speed < filters.min_speed ) { toast.error( - "The 'max_speed' must be greater than or equal to the 'min_speed'.", + t("filter.toast.error.maxSpeedMustBeGreaterOrEqualMinSpeed"), { position: "top-center", }, @@ -380,7 +376,7 @@ export default function InputWithTags({ setCurrentFilterType(null); } }, - [filters, setFilters, allSuggestions], + [filters, setFilters, allSuggestions, t], ); function formatFilterValues( @@ -408,16 +404,26 @@ export default function InputWithTags({ return Math.round(Number(filterValues) * 100).toString() + "%"; } else if (filterType === "min_speed" || filterType === "max_speed") { return ( - filterValues + (config?.ui.unit_system == "metric" ? " kph" : " mph") + filterValues + + " " + + (config?.ui.unit_system == "metric" + ? t("unit.speed.kph", { ns: "common" }) + : t("unit.speed.mph", { ns: "common" })) ); } else if ( filterType === "has_clip" || filterType === "has_snapshot" || filterType === "is_submitted" ) { - return filterValues ? "Yes" : "No"; + return filterValues + ? t("button.yes", { ns: "common" }) + : t("button.no", { ns: "common" }); + } else if (filterType === "labels") { + return t(filterValues as string, { ns: "objects" }); + } else if (filterType === "search_type") { + return t("filter.searchType." + (filterValues as string)); } else { - return filterValues as string; + return (filterValues as string).replaceAll("_", " "); } } @@ -653,7 +659,7 @@ export default function InputWithTags({ onBlur={handleInputBlur} onKeyDown={handleInputKeyDown} className="text-md h-9 pr-32" - placeholder="Search..." + placeholder={t("placeholder.search")} />
    {(search || Object.keys(filters).length > 0) && ( @@ -665,7 +671,7 @@ export default function InputWithTags({ /> - Clear search + {t("button.clear")} )} @@ -679,7 +685,7 @@ export default function InputWithTags({ /> - Save search + {t("button.save")} )} @@ -688,12 +694,14 @@ export default function InputWithTags({ - Similarity search active + + {t("similaritySearch.active")} + )} @@ -702,10 +710,10 @@ export default function InputWithTags({ @@ -816,8 +805,8 @@ export default function InputWithTags({ key={`${filterType}-${index}`} className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm capitalize text-green-800" > - {filterType.replaceAll("_", " ")}:{" "} - {value.replaceAll("_", " ")} + {t("filter.label." + filterType)}:{" "} + {formatFilterValues(filterType, value)} diff --git a/web/src/components/menu/AccountSettings.tsx b/web/src/components/menu/AccountSettings.tsx index 7e948308f..061a99e2a 100644 --- a/web/src/components/menu/AccountSettings.tsx +++ b/web/src/components/menu/AccountSettings.tsx @@ -20,16 +20,19 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { DialogClose } from "../ui/dialog"; import { LuLogOut, LuSquarePen } from "react-icons/lu"; import useSWR from "swr"; + import { useState } from "react"; import axios from "axios"; import { toast } from "sonner"; import SetPasswordDialog from "../overlay/SetPasswordDialog"; +import { useTranslation } from "react-i18next"; type AccountSettingsProps = { className?: string; }; export default function AccountSettings({ className }: AccountSettingsProps) { + const { t } = useTranslation(["views/settings"]); const { data: profile } = useSWR("profile"); const { data: config } = useSWR("config"); const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`; @@ -48,7 +51,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) { .then((response) => { if (response.status === 200) { setPasswordDialogOpen(false); - toast.success("Password updated successfully.", { + toast.success(t("users.toast.success.updatePassword"), { position: "top-center", }); } @@ -58,9 +61,14 @@ export default function AccountSettings({ className }: AccountSettingsProps) { error.response?.data?.message || error.response?.data?.detail || "Unknown error"; - toast.error(`Error setting password: ${errorMessage}`, { - position: "top-center", - }); + toast.error( + t("users.toast.error.setPasswordFailed", { + errorMessage, + }), + { + position: "top-center", + }, + ); }); }; @@ -83,7 +91,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) { -

    Account

    +

    {t("menu.user.account", { ns: "common" })}

    @@ -95,8 +103,13 @@ export default function AccountSettings({ className }: AccountSettingsProps) { >
    - Current User: {profile?.username || "anonymous"}{" "} - {profile?.role && `(${profile.role})`} + {t("menu.user.current", { + ns: "common", + user: + profile?.username || t("menu.user.anonymous", { ns: "common" }), + })}{" "} + {t("role." + profile?.role) && + `(${t("role." + profile?.role, { ns: "common" })})`} {profile?.username && profile.username !== "anonymous" && ( @@ -104,22 +117,22 @@ export default function AccountSettings({ className }: AccountSettingsProps) { className={ isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" } - aria-label="Set Password" + aria-label={t("menu.user.setPassword", { ns: "common" })} onClick={() => setPasswordDialogOpen(true)} > - Set Password + {t("menu.user.setPassword", { ns: "common" })} )} - Logout + {t("menu.user.logout", { ns: "common" })}
    diff --git a/web/src/components/menu/GeneralSettings.tsx b/web/src/components/menu/GeneralSettings.tsx index 55d180a42..45e567868 100644 --- a/web/src/components/menu/GeneralSettings.tsx +++ b/web/src/components/menu/GeneralSettings.tsx @@ -1,6 +1,7 @@ import { LuActivity, LuGithub, + LuLanguages, LuLifeBuoy, LuList, LuLogOut, @@ -10,6 +11,7 @@ import { LuSettings, LuSun, LuSunMoon, + LuEarth, } from "react-icons/lu"; import { DropdownMenu, @@ -52,21 +54,28 @@ import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; import useSWR from "swr"; import RestartDialog from "../overlay/dialog/RestartDialog"; + +import { useLanguage } from "@/context/language-provider"; import { useIsAdmin } from "@/hooks/use-is-admin"; import SetPasswordDialog from "../overlay/SetPasswordDialog"; import { toast } from "sonner"; import axios from "axios"; import { FrigateConfig } from "@/types/frigateConfig"; +import { useTranslation } from "react-i18next"; type GeneralSettingsProps = { className?: string; }; export default function GeneralSettings({ className }: GeneralSettingsProps) { + const { t } = useTranslation(["common"]); const { data: profile } = useSWR("profile"); const { data: config } = useSWR("config"); const logoutUrl = config?.proxy?.logout_url || "/api/logout"; + // settings + + const { language, setLanguage, systemLanguage } = useLanguage(); const { theme, colorScheme, setTheme, setColorScheme } = useTheme(); const [restartDialogOpen, setRestartDialogOpen] = useState(false); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); @@ -90,9 +99,12 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { .then((response) => { if (response.status === 200) { setPasswordDialogOpen(false); - toast.success("Password updated successfully.", { - position: "top-center", - }); + toast.success( + t("users.toast.success.updatePassword", { ns: "views/settings" }), + { + position: "top-center", + }, + ); } }) .catch((error) => { @@ -100,9 +112,15 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { error.response?.data?.message || error.response?.data?.detail || "Unknown error"; - toast.error(`Error setting password: ${errorMessage}`, { - position: "top-center", - }); + toast.error( + t("users.toast.error.setPasswordFailed", { + ns: "views/settings", + errorMessage, + }), + { + position: "top-center", + }, + ); }); }; @@ -126,7 +144,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { -

    Settings

    +

    {t("menu.settings")}

    @@ -150,8 +168,11 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { {isMobile && (
    - Current User: {profile?.username || "anonymous"}{" "} - {profile?.role && `(${profile.role})`} + {t("menu.user.current", { + user: profile?.username || t("menu.user.anonymous"), + })}{" "} + {t("role." + profile?.role) && + `(${t("role." + profile?.role)})`} setPasswordDialogOpen(true)} > - Set Password + {t("menu.user.setPassword")} )} - Logout + {t("menu.user.logout", { ns: "common" })}
    )} {isAdmin && ( <> - System + {t("menu.system")} @@ -197,10 +218,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { ? "cursor-pointer" : "flex w-full items-center p-2 text-sm" } - aria-label="System metrics" + aria-label={t("menu.systemMetrics")} > - System metrics + {t("menu.systemMetrics")} @@ -210,10 +231,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { ? "cursor-pointer" : "flex w-full items-center p-2 text-sm" } - aria-label="System logs" + aria-label={t("menu.systemLogs")} > - System logs + {t("menu.systemLogs")} @@ -222,7 +243,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { - Configuration + {t("menu.configuration")} @@ -233,10 +254,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { ? "cursor-pointer" : "flex w-full items-center p-2 text-sm" } - aria-label="Settings" + aria-label={t("menu.settings")} > - Settings + {t("menu.settings")} {isAdmin && ( @@ -248,10 +269,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { ? "cursor-pointer" : "flex w-full items-center p-2 text-sm" } - aria-label="Configuration editor" + aria-label={t("menu.configurationEditor")} > - Configuration editor + {t("menu.configurationEditor")} @@ -271,7 +292,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { )} - Appearance + {t("menu.appearance")} @@ -280,8 +301,8 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" } > - - Dark Mode + + {t("menu.languages")} setTheme("light")} + aria-label={t("menu.language.en")} + onClick={() => setLanguage("en")} > - {theme === "light" ? ( + {language.trim() === "en" ? ( <> - - Light + + {t("menu.language.en")} ) : ( - Light + {t("menu.language.en")} )} setTheme("dark")} + aria-label={t("menu.language.zhCN")} + onClick={() => setLanguage("zh-CN")} > - {theme === "dark" ? ( + {language === "zh-CN" ? ( <> - - Dark + + {t("menu.language.zhCN")} ) : ( - Dark + + {t("menu.language.zhCN")} + )} setTheme("system")} + aria-label={t("menu.language.withSystem.label")} + onClick={() => setLanguage(systemLanguage)} > - {theme === "system" ? ( + {language === systemLanguage ? ( <> - - System + + {t("menu.withSystem")} ) : ( - System + {t("menu.withSystem")} )} @@ -354,7 +377,84 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { } > - Theme + {t("menu.darkMode.label")} + + + + + setTheme("light")} + > + {theme === "light" ? ( + <> + + {t("menu.darkMode.light")} + + ) : ( + + {t("menu.darkMode.light")} + + )} + + setTheme("dark")} + > + {theme === "dark" ? ( + <> + + {t("menu.darkMode.dark")} + + ) : ( + + {t("menu.darkMode.dark")} + + )} + + setTheme("system")} + > + {theme === "system" ? ( + <> + + {t("menu.withSystem")} + + ) : ( + {t("menu.withSystem")} + )} + + + + + + + + {t("menu.theme.label")} - {friendlyColorSchemeName(scheme)} + {t(friendlyColorSchemeName(scheme))} ) : ( - {friendlyColorSchemeName(scheme)} + {t(friendlyColorSchemeName(scheme))} )} @@ -390,7 +490,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { - Help + {t("menu.help")} @@ -398,10 +498,10 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { className={ isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" } - aria-label="Frigate documentation" + aria-label={t("menu.documentation.label")} > - Documentation + {t("menu.documentation")} setRestartDialogOpen(true)} > - Restart Frigate + {t("menu.restart")} )} diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index 9c775e0ac..52af5b9b7 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -44,6 +44,7 @@ import { useNotifications, useNotificationSuspend, } from "@/api/ws"; +import { useTranslation } from "react-i18next"; type LiveContextMenuProps = { className?: string; @@ -85,6 +86,7 @@ export default function LiveContextMenu({ config, children, }: LiveContextMenuProps) { + const { t } = useTranslation("views/live"); const [showSettings, setShowSettings] = useState(false); // camera enabled @@ -209,7 +211,7 @@ export default function LiveContextMenu({ // notifications const notificationsEnabledInConfig = - config?.cameras[camera].notifications.enabled_in_config; + config?.cameras[camera]?.notifications?.enabled_in_config; const { payload: notificationState, send: sendNotification } = useNotifications(camera); @@ -234,14 +236,19 @@ export default function LiveContextMenu({ }; const formatSuspendedUntil = (timestamp: string) => { - if (timestamp === "0") return "Frigate restarts."; + // Some languages require a change in word order + if (timestamp === "0") return t("time.untilForRestart", { ns: "common" }); - return formatUnixTimestampToDateTime(Number.parseInt(timestamp), { + const time = formatUnixTimestampToDateTime(Number.parseInt(timestamp), { time_style: "medium", date_style: "medium", timezone: config?.ui.timezone, - strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`, + strftime_fmt: + config?.ui.time_format == "24hour" + ? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" }) + : t("time.formattedTimestampExcludeSeconds", { ns: "common" }), }); + return t("time.untilForTime", { ns: "common", time }); }; return ( @@ -256,7 +263,7 @@ export default function LiveContextMenu({ {preferredLiveMode == "jsmpeg" && isRestreamed && (
    -

    Low-bandwidth mode

    +

    {t("lowBandwidthMode")}

    )}
    @@ -265,7 +272,7 @@ export default function LiveContextMenu({
    -

    Audio

    +

    {t("audio")}

    sendEnabled(isEnabled ? "OFF" : "ON")} >
    - {isEnabled ? "Disable" : "Enable"} Camera + {isEnabled ? t("camera.disable") : t("camera.enable")}
    @@ -302,7 +309,7 @@ export default function LiveContextMenu({ className="flex w-full cursor-pointer items-center justify-start gap-2" onClick={isEnabled ? muteAll : undefined} > -
    Mute All Cameras
    +
    {t("muteCameras.enable")}
    @@ -310,7 +317,7 @@ export default function LiveContextMenu({ className="flex w-full cursor-pointer items-center justify-start gap-2" onClick={isEnabled ? unmuteAll : undefined} > -
    Unmute All Cameras
    +
    {t("muteCameras.disable")}
    @@ -320,7 +327,9 @@ export default function LiveContextMenu({ onClick={isEnabled ? toggleStats : undefined} >
    - {statsState ? "Hide" : "Show"} Stream Stats + {statsState + ? t("streamStats.disable") + : t("streamStats.enable")}
    @@ -333,7 +342,9 @@ export default function LiveContextMenu({ : undefined } > -
    Debug View
    +
    + {t("streaming.debugView", { ns: "components/dialog" })} +
    {cameraGroup && cameraGroup !== "default" && ( @@ -344,7 +355,7 @@ export default function LiveContextMenu({ className="flex w-full cursor-pointer items-center justify-start gap-2" onClick={isEnabled ? () => setShowSettings(true) : undefined} > -
    Streaming Settings
    +
    {t("streamingSettings")}
    @@ -357,7 +368,9 @@ export default function LiveContextMenu({ className="flex w-full cursor-pointer items-center justify-start gap-2" onClick={isEnabled ? resetPreferredLiveMode : undefined} > -
    Reset
    +
    + {t("button.reset", { ns: "common" })} +
    @@ -368,7 +381,7 @@ export default function LiveContextMenu({
    - Notifications + {t("notifications")}
    @@ -379,25 +392,29 @@ export default function LiveContextMenu({ {isSuspended ? ( <> - Suspended + + {t("button.suspended", { ns: "common" })} + ) : ( <> - Enabled + + {t("button.enabled", { ns: "common" })} + )} ) : ( <> - Disabled + {t("button.disabled", { ns: "common" })} )} {isSuspended && ( - Until {formatSuspendedUntil(notificationSuspendUntil)} + {formatSuspendedUntil(notificationSuspendUntil)} )} @@ -418,9 +435,11 @@ export default function LiveContextMenu({ >
    {notificationState === "ON" ? ( - Unsuspend + + {t("button.unsuspended", { ns: "common" })} + ) : ( - Enable + {t("button.enable", { ns: "common" })} )}
    @@ -431,7 +450,7 @@ export default function LiveContextMenu({

    - Suspend for: + {t("suspend.forTime")}

    handleSuspend("5") : undefined } > - 5 minutes + {t("time.5minutes", { ns: "common" })} - 10 minutes + {t("time.10minutes", { ns: "common" })} - 30 minutes + {t("time.30minutes", { ns: "common" })} - 1 hour + {t("time.1hour", { ns: "common" })} - 12 hours + {t("time.12hours", { ns: "common" })} - 24 hours + {t("time.24hours", { ns: "common" })} - Until restart + {t("time.untilRestart", { ns: "common" })}
    diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 4d1fd4966..b6d664b39 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -40,6 +40,8 @@ import { } from "@/components/ui/tooltip"; import useSWR from "swr"; +import { Trans, useTranslation } from "react-i18next"; + type SearchResultActionsProps = { searchResult: SearchResult; findSimilar: () => void; @@ -59,6 +61,8 @@ export default function SearchResultActions({ isContextMenu = false, children, }: SearchResultActionsProps) { + const { t } = useTranslation(["views/explore"]); + const { data: config } = useSWR("config"); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -68,7 +72,7 @@ export default function SearchResultActions({ .delete(`events/${searchResult.id}`) .then((resp) => { if (resp.status == 200) { - toast.success("Tracked object deleted successfully.", { + toast.success(t("searchResult.deleteTrackedObject.toast.success"), { position: "top-center", }); refreshResults(); @@ -79,9 +83,12 @@ export default function SearchResultActions({ error.response?.data?.message || error.response?.data?.detail || "Unknown error"; - toast.error(`Failed to delete tracked object: ${errorMessage}`, { - position: "top-center", - }); + toast.error( + t("searchResult.deleteTrackedObject.toast.error", { errorMessage }), + { + position: "top-center", + }, + ); }); }; @@ -90,45 +97,45 @@ export default function SearchResultActions({ const menuItems = ( <> {searchResult.has_clip && ( - +
    - Download video + {t("itemMenu.downloadVideo")} )} {searchResult.has_snapshot && ( - + - Download snapshot + {t("itemMenu.downloadSnapshot.label")} )} {searchResult.data.type == "object" && ( - View object lifecycle + {t("itemMenu.viewObjectLifecycle.label")} )} {config?.semantic_search?.enabled && isContextMenu && ( - Find similar + {t("itemMenu.findSimilar.label")} )} {isMobileOnly && @@ -137,17 +144,20 @@ export default function SearchResultActions({ searchResult.end_time && searchResult.data.type == "object" && !searchResult.plus_id && ( - + - Submit to Frigate+ + {t("itemMenu.submitToPlus")} )} setDeleteDialogOpen(true)} > - Delete + {t("button.delete", { ns: "common" })} ); @@ -160,24 +170,20 @@ export default function SearchResultActions({ > - Confirm Delete + {t("dialog.confirmDelete")} - 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 NOT be - deleted. -
    -
    - Are you sure you want to proceed? + dialog.confirmDelete.desc
    - Cancel + + {t("button.cancel", { ns: "common" })} + - Delete + {t("button.delete", { ns: "common" })}
    @@ -198,7 +204,9 @@ export default function SearchResultActions({ onClick={findSimilar} /> - Find similar + + {t("itemMenu.findSimilar.label")} + )} @@ -215,7 +223,9 @@ export default function SearchResultActions({ onClick={showSnapshot} /> - Submit to Frigate+ + + {t("itemMenu.submitToPlus.label")} + )} diff --git a/web/src/components/mobile/MobilePage.tsx b/web/src/components/mobile/MobilePage.tsx index 169b5e524..8330f0a64 100644 --- a/web/src/components/mobile/MobilePage.tsx +++ b/web/src/components/mobile/MobilePage.tsx @@ -5,6 +5,7 @@ import { IoMdArrowRoundBack } from "react-icons/io"; import { cn } from "@/lib/utils"; import { isPWA } from "@/utils/isPWA"; import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; const MobilePageContext = createContext<{ open: boolean; @@ -138,6 +139,7 @@ export function MobilePageHeader({ onClose, ...props }: MobilePageHeaderProps) { + const { t } = useTranslation(["common"]); const context = useContext(MobilePageContext); if (!context) throw new Error("MobilePageHeader must be used within MobilePage"); @@ -160,7 +162,7 @@ export function MobilePageHeader({ > diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx index 89403c37f..8c886ea7e 100644 --- a/web/src/components/overlay/CreateUserDialog.tsx +++ b/web/src/components/overlay/CreateUserDialog.tsx @@ -22,6 +22,7 @@ import { DialogHeader, DialogTitle, } from "../ui/dialog"; + import { Select, SelectContent, @@ -31,6 +32,7 @@ import { } from "../ui/select"; import { Shield, User } from "lucide-react"; import { LuCheck, LuX } from "react-icons/lu"; +import { useTranslation } from "react-i18next"; type CreateUserOverlayProps = { show: boolean; @@ -43,22 +45,23 @@ export default function CreateUserDialog({ onCreate, onCancel, }: CreateUserOverlayProps) { + const { t } = useTranslation(["views/settings"]); const [isLoading, setIsLoading] = useState(false); const formSchema = z .object({ user: z .string() - .min(1, "Username is required") + .min(1, t("users.dialog.form.usernameIsRequired")) .regex(/^[A-Za-z0-9._]+$/, { - message: "Username may only include letters, numbers, . or _", + message: t("users.dialog.createUser.usernameOnlyInclude"), }), password: z.string().min(1, "Password is required"), confirmPassword: z.string().min(1, "Please confirm your password"), role: z.enum(["admin", "viewer"]), }) .refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", + message: t("users.dialog.form.password.notMatch"), path: ["confirmPassword"], }); @@ -109,10 +112,9 @@ export default function CreateUserDialog({ - Create New User + {t("users.dialog.createUser.title")} - Add a new user account and specify an role for access to areas of - the Frigate UI. + {t("users.dialog.createUser.desc")} @@ -126,17 +128,17 @@ export default function CreateUserDialog({ render={({ field }) => ( - Username + {t("users.dialog.form.user")} - Only letters, numbers, periods and underscores allowed. + {t("users.dialog.form.user.desc")} @@ -148,11 +150,11 @@ export default function CreateUserDialog({ render={({ field }) => ( - Password + {t("users.dialog.form.password")} ( - Confirm Password + {t("users.dialog.form.password.confirm")} - Passwords match + {t("users.dialog.form.password.match")} ) : ( <> - Passwords don't match + {t("users.dialog.form.password.notMatch")} )} @@ -206,7 +210,9 @@ export default function CreateUserDialog({ name="role" render={({ field }) => ( - Role + + {t("role.title", { ns: "common" })} + - 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. + {t("role.desc", { ns: "common" })} @@ -252,16 +256,16 @@ export default function CreateUserDialog({
    ) : ( - "Save" + t("button.save", { ns: "common" }) )} diff --git a/web/src/components/overlay/DeleteUserDialog.tsx b/web/src/components/overlay/DeleteUserDialog.tsx index e8dfb79c1..1e91196b4 100644 --- a/web/src/components/overlay/DeleteUserDialog.tsx +++ b/web/src/components/overlay/DeleteUserDialog.tsx @@ -1,10 +1,10 @@ +import { useTranslation } from "react-i18next"; import { Button } from "../ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, - DialogTitle, } from "../ui/dialog"; import { DialogDescription } from "@radix-ui/react-dialog"; @@ -20,23 +20,22 @@ export default function DeleteUserDialog({ onDelete, onCancel, }: DeleteUserDialogProps) { + const { t } = useTranslation(["views/settings"]); return (
    - Delete User + {t("users.dialog.deleteUser.title")} - This action cannot be undone. This will permanently delete the - user account and remove all associated data. + {t("users.dialog.deleteUser.desc")}

    - Are you sure you want to delete{" "} - {username}? + {t("users.dialog.deleteUser.warn", { username })}

    @@ -45,19 +44,19 @@ export default function DeleteUserDialog({
    diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 4f49abaf0..0f373057d 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -30,6 +30,7 @@ import { getUTCOffset } from "@/utils/dateUtil"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { GenericVideoPlayer } from "../player/GenericVideoPlayer"; +import { useTranslation } from "react-i18next"; const EXPORT_OPTIONS = [ "1", @@ -64,16 +65,19 @@ export default function ExportDialog({ setMode, setShowPreview, }: ExportDialogProps) { + const { t } = useTranslation(["components/dialog"]); const [name, setName] = useState(""); const onStartExport = useCallback(() => { if (!range) { - toast.error("No valid time range selected", { position: "top-center" }); + toast.error(t("export.toast.error.noVaildTimeSelected"), { + position: "top-center", + }); return; } if (range.before < range.after) { - toast.error("End time must be after start time", { + toast.error(t("export.toast.error.endTimeMustAfterStartTime"), { position: "top-center", }); return; @@ -89,10 +93,9 @@ export default function ExportDialog({ ) .then((response) => { if (response.status == 200) { - toast.success( - "Successfully started export. View the file in the /exports folder.", - { position: "top-center" }, - ); + toast.success(t("export.toast.success"), { + position: "top-center", + }); setName(""); setRange(undefined); setMode("none"); @@ -103,11 +106,14 @@ export default function ExportDialog({ error.response?.data?.message || error.response?.data?.detail || "Unknown error"; - toast.error(`Failed to start export: ${errorMessage}`, { - position: "top-center", - }); + toast.error( + t("export.toast.error.failed", { + error: errorMessage, + }), + { position: "top-center" }, + ); }); - }, [camera, name, range, setRange, setName, setMode]); + }, [camera, name, range, setRange, setName, setMode, t]); const handleCancel = useCallback(() => { setName(""); @@ -145,7 +151,7 @@ export default function ExportDialog({ ("1"); const onSelectTime = useCallback( @@ -256,7 +267,7 @@ export function ExportContent({ {isDesktop && ( <> - Export + {t("menu.export")} @@ -280,9 +291,11 @@ export function ExportContent({ ); @@ -298,7 +311,7 @@ export function ExportContent({ setName(e.target.value)} /> @@ -310,11 +323,11 @@ export function ExportContent({ className={`cursor-pointer p-2 text-center ${isDesktop ? "" : "w-full"}`} onClick={onCancel} > - Cancel + {t("button.cancel", { ns: "common" })} @@ -345,6 +360,7 @@ function CustomTimeSelector({ range, setRange, }: CustomTimeSelectorProps) { + const { t } = useTranslation(["components/dialog"]); const { data: config } = useSWR("config"); // times @@ -388,14 +404,14 @@ function CustomTimeSelector({ const formattedStart = useFormattedTimestamp( startTime, config?.ui.time_format == "24hour" - ? "%b %-d, %H:%M:%S" - : "%b %-d, %I:%M:%S %p", + ? t("time.formattedTimestamp.24hour") + : t("time.formattedTimestamp"), ); const formattedEnd = useFormattedTimestamp( endTime, config?.ui.time_format == "24hour" - ? "%b %-d, %H:%M:%S" - : "%b %-d, %I:%M:%S %p", + ? t("time.formattedTimestamp.24hour") + : t("time.formattedTimestamp"), ); const startClock = useMemo(() => { @@ -428,7 +444,7 @@ function CustomTimeSelector({
    @@ -81,34 +94,52 @@ export default function GPUInfoDialog({ - Nvidia SMI Output + + {t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.title")} + {nvinfo ? (
    -
    Name: {nvinfo["0"].name}
    +
    + {t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", { + name: nvinfo["0"].name, + })} +

    -
    Driver: {nvinfo["0"].driver}
    +
    + {t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", { + name: nvinfo["0"].driver, + })} +

    -
    Cuda Compute Capability: {nvinfo["0"].cuda_compute}
    +
    + {t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", { + name: nvinfo["0"].cuda_compute, + })} +

    -
    VBios Info: {nvinfo["0"].vbios}
    +
    + {t("general.hardwareInfo.gpuInfo.nvidiaSMIOutput.name", { + name: nvinfo["0"].vbios, + })} +
    ) : ( )}
    diff --git a/web/src/components/overlay/MobileCameraDrawer.tsx b/web/src/components/overlay/MobileCameraDrawer.tsx index c12bc0ab2..742de1242 100644 --- a/web/src/components/overlay/MobileCameraDrawer.tsx +++ b/web/src/components/overlay/MobileCameraDrawer.tsx @@ -3,6 +3,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import { Button } from "../ui/button"; import { FaVideo } from "react-icons/fa"; import { isMobile } from "react-device-detect"; +import { useTranslation } from "react-i18next"; type MobileCameraDrawerProps = { allCameras: string[]; @@ -14,6 +15,7 @@ export default function MobileCameraDrawer({ selected, onSelectCamera, }: MobileCameraDrawerProps) { + const { t } = useTranslation(["common"]); const [cameraDrawer, setCameraDrawer] = useState(false); if (!isMobile) { @@ -25,7 +27,7 @@ export default function MobileCameraDrawer({ )} {features.includes("calendar") && ( )} {features.includes("filter") && ( )} @@ -206,10 +219,10 @@ export default function MobileReviewSettingsDrawer({ className="absolute left-0 text-selected" onClick={() => setDrawerMode("select")} > - Back + {t("button.back", { ns: "common" })}
    - Calendar + {t("calendar")}
    @@ -234,7 +247,7 @@ export default function MobileReviewSettingsDrawer({
    @@ -256,10 +269,10 @@ export default function MobileReviewSettingsDrawer({ className="absolute left-0 text-selected" onClick={() => setDrawerMode("select")} > - Back + {t("button.back", { ns: "common" })}
    - Filter + {t("filter")}
    diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx index df529c0dc..d9e08db65 100644 --- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx +++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx @@ -26,6 +26,7 @@ import { Button } from "@/components/ui/button"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; +import { Trans, useTranslation } from "react-i18next"; type AnnotationSettingsPaneProps = { event: Event; @@ -41,6 +42,8 @@ export function AnnotationSettingsPane({ annotationOffset, setAnnotationOffset, }: AnnotationSettingsPaneProps) { + const { t } = useTranslation(["views/explore"]); + const { data: config, mutate: updateConfig } = useSWR("config"); @@ -81,9 +84,15 @@ export function AnnotationSettingsPane({ ); updateConfig(); } else { - toast.error(`Failed to save config changes: ${res.statusText}`, { - position: "top-center", - }); + toast.error( + t("toast.save.error", { + errorMessage: res.statusText, + ns: "common", + }), + { + position: "top-center", + }, + ); } }) .catch((error) => { @@ -91,7 +100,7 @@ export function AnnotationSettingsPane({ error.response?.data?.message || error.response?.data?.detail || "Unknown error"; - toast.error(`Failed to save config changes: ${errorMessage}`, { + toast.error(t("toast.save.error", { errorMessage, ns: "common" }), { position: "top-center", }); }) @@ -99,7 +108,7 @@ export function AnnotationSettingsPane({ setIsLoading(false); }); }, - [updateConfig, config, event], + [updateConfig, config, event, t], ); function onSubmit(values: z.infer) { @@ -126,7 +135,7 @@ export function AnnotationSettingsPane({ return (
    - Annotation Settings + {t("objectLifecycle.annotationSettings.title")}
    @@ -136,11 +145,11 @@ export function AnnotationSettingsPane({ onCheckedChange={setShowZones} />
    - Always show zones on frames where objects have entered a zone. + {t("objectLifecycle.annotationSettings.showAllZones.desc")}
    @@ -154,17 +163,16 @@ export function AnnotationSettingsPane({ name="annotationOffset" render={({ field }) => ( - Annotation Offset + + {t("objectLifecycle.annotationSettings.offset.label")} +
    - 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 annotation_offset{" "} - field can be used to adjust this. + + objectLifecycle.annotationSettings.offset.desc +
    - Read the documentation{" "} + {t( + "objectLifecycle.annotationSettings.offset.documentation", + )}
    @@ -187,16 +197,11 @@ export function AnnotationSettingsPane({ /> - Milliseconds to offset detect annotations by.{" "} - Default: 0 + {t( + "objectLifecycle.annotationSettings.offset.millisecondsToOffset", + )}
    - 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. + {t("objectLifecycle.annotationSettings.offset.tips")}
    @@ -210,14 +215,14 @@ export function AnnotationSettingsPane({
    ) : ( - "Save" + t("button.save", { ns: "common" }) )}
    diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx index da9bd61b0..1809ff56f 100644 --- a/web/src/components/overlay/detail/ObjectLifecycle.tsx +++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx @@ -54,6 +54,7 @@ import { useNavigate } from "react-router-dom"; import { ObjectPath } from "./ObjectPath"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { IoPlayCircleOutline } from "react-icons/io5"; +import { useTranslation } from "react-i18next"; type ObjectLifecycleProps = { className?: string; @@ -68,6 +69,8 @@ export default function ObjectLifecycle({ fullscreen = false, setPane, }: ObjectLifecycleProps) { + const { t } = useTranslation(["views/explore"]); + const { data: eventSequence } = useSWR([ "timeline", { @@ -334,12 +337,16 @@ export default function ObjectLifecycle({
    )} @@ -361,7 +368,7 @@ export default function ObjectLifecycle({
    - No image found for this timestamp. + {t("objectLifecycle.noImageFound")}
    )} @@ -464,11 +471,13 @@ export default function ObjectLifecycle({ className="flex w-full cursor-pointer items-center justify-start gap-2 p-2" onClick={() => navigate( - `/settings?page=masks%20/%20zones&camera=${event.camera}&object_mask=${eventSequence?.[current].data.box}`, + `/settings?page=masksAndZones&camera=${event.camera}&object_mask=${eventSequence?.[current].data.box}`, ) } > -
    Create Object Mask
    +
    + {t("objectLifecycle.createObjectMask")} +
    @@ -477,7 +486,7 @@ export default function ObjectLifecycle({
    - Object Lifecycle + {t("objectLifecycle.title")}
    @@ -485,7 +494,7 @@ export default function ObjectLifecycle({
    - Scroll to view the significant moments of this object's lifecycle. + {t("objectLifecycle.scrollViewTips")}
    {current + 1} of {eventSequence.length} @@ -509,7 +520,7 @@ export default function ObjectLifecycle({
    {config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
    - Bounding box positions will be inaccurate for autotracking cameras. + {t("objectLifecycle.autoTrackingTips")}
    )} {showControls && ( @@ -559,8 +570,8 @@ export default function ObjectLifecycle({ timezone: config.ui.timezone, strftime_fmt: config.ui.time_format == "24hour" - ? "%d %b %H:%M:%S" - : "%m/%d %I:%M:%S%P", + ? t("time.formattedTimestamp2.24hour") + : t("time.formattedTimestamp2"), time_style: "medium", date_style: "medium", })} diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index 2570fd033..f27543484 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -42,6 +42,7 @@ import { DownloadVideoButton } from "@/components/button/DownloadVideoButton"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { LuSearch } from "react-icons/lu"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; +import { Trans, useTranslation } from "react-i18next"; type ReviewDetailDialogProps = { review?: ReviewSegment; @@ -51,6 +52,7 @@ export default function ReviewDetailDialog({ review, setReview, }: ReviewDetailDialogProps) { + const { t } = useTranslation(["views/explore"]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); @@ -95,8 +97,8 @@ export default function ReviewDetailDialog({ const formattedDate = useFormattedTimestamp( review?.start_time ?? 0, config?.ui.time_format == "24hour" - ? "%b %-d %Y, %H:%M" - : "%b %-d %Y, %I:%M %p", + ? t("time.formattedTimestampWithYear.24hour", { ns: "common" }) + : t("time.formattedTimestampWithYear", { ns: "common" }), config?.ui.timezone, ); @@ -177,8 +179,10 @@ export default function ReviewDetailDialog({ {pane == "overview" && (
    - Review Item Details - Review item details + {t("details.item.title")} + + {t("details.item.desc")} +
    - Share this review item + + {t("details.item.button.share")} + @@ -211,7 +217,9 @@ export default function ReviewDetailDialog({ /> - Download + + {t("button.download", { ns: "common" })} +
    @@ -222,19 +230,25 @@ export default function ReviewDetailDialog({
    -
    Camera
    +
    + {t("details.camera")} +
    {review.camera.replaceAll("_", " ")}
    -
    Timestamp
    +
    + {t("details.timestamp")} +
    {formattedDate}
    -
    Objects
    +
    + {t("details.objects")} +
    {events?.map((event) => { return ( @@ -260,7 +274,9 @@ export default function ReviewDetailDialog({
    - View in Explore + + {t("details.item.button.viewInExplore")} +
    @@ -270,7 +286,9 @@ export default function ReviewDetailDialog({
    {review.data.zones.length > 0 && (
    -
    Zones
    +
    + {t("details.zones")} +
    {review.data.zones.map((zone) => { return ( @@ -294,18 +312,23 @@ export default function ReviewDetailDialog({ (events?.length ?? 0) - (review?.data.detections.length ?? 0), ); - const objectLabel = - detectedCount === 1 ? "object was" : "objects were"; - return `${detectedCount} unavailable ${objectLabel} 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. + return t("details.item.tips.mismatch", { + count: detectedCount, + }); + })()} {missingObjects.length > 0 && (
    - Adjust your configuration if you want Frigate to save - tracked objects for the following labels:{" "} - {missingObjects.join(", ")} + t(x, { ns: "objects" })) + .join(", "), + }} + > + details.item.tips.hasMissingObjects +
    )}
    @@ -348,6 +371,8 @@ function EventItem({ setSelectedEvent, setUpload, }: EventItemProps) { + const { t } = useTranslation(["views/explore"]); + const { data: config } = useSWR("config", { revalidateOnFocus: false, }); @@ -417,7 +442,9 @@ function EventItem({ - Download + + {t("button.download", { ns: "common" })} + {event.has_snapshot && @@ -435,7 +462,9 @@ function EventItem({ - Submit to Frigate+ + + {t("itemMenu.submitToPlus.label")} + )} @@ -452,7 +481,9 @@ function EventItem({ - View Object Lifecycle + + {t("itemMenu.viewObjectLifecycle.label")} + )} @@ -470,7 +501,9 @@ function EventItem({ - Find Similar + + {t("itemMenu.findSimilar.label")} + )}
    diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index ed472c742..9bc384861 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -73,12 +73,13 @@ import { LuInfo } from "react-icons/lu"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { FaPencilAlt } from "react-icons/fa"; import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; +import { useTranslation } from "react-i18next"; const SEARCH_TABS = [ "details", "snapshot", "video", - "object lifecycle", + "object_lifecycle", ] as const; export type SearchTab = (typeof SEARCH_TABS)[number]; @@ -98,6 +99,7 @@ export default function SearchDetailDialog({ setSimilarity, setInputFocused, }: SearchDetailDialogProps) { + const { t } = useTranslation(["views/explore"]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); @@ -152,7 +154,7 @@ export default function SearchDetailDialog({ } if (search.data.type != "object" || !search.has_clip) { - const index = views.indexOf("object lifecycle"); + const index = views.indexOf("object_lifecycle"); views.splice(index, 1); } @@ -192,8 +194,8 @@ export default function SearchDetailDialog({ )} >
    - Tracked Object Details - Tracked object details + {t("trackedObjectDetails")} + {t("details")}
    } {item == "snapshot" && } {item == "video" && } - {item == "object lifecycle" && ( + {item == "object_lifecycle" && ( )} -
    {item}
    +
    {t("type.{item}")}
    ))} @@ -254,7 +256,7 @@ export default function SearchDetailDialog({ /> )} {page == "video" && } - {page == "object lifecycle" && ( + {page == "object_lifecycle" && ( { if (resp.status == 200) { - toast.success("Successfully saved description", { + toast.success(t("details.tips.descriptionSaved"), { position: "top-center", }); } @@ -416,12 +420,17 @@ function ObjectDetailsTab({ error.response?.data?.message || error.response?.data?.detail || "Unknown error"; - toast.error(`Failed to update the description: ${errorMessage}`, { - position: "top-center", - }); + toast.error( + t("details.tips.saveDescriptionFailed", { + errorMessage, + }), + { + position: "top-center", + }, + ); setDesc(search.data.description); }); - }, [desc, search, mutate]); + }, [desc, search, mutate, t]); const regenerateDescription = useCallback( (source: "snapshot" | "thumbnails") => { @@ -434,7 +443,12 @@ function ObjectDetailsTab({ .then((resp) => { if (resp.status == 200) { toast.success( - `A new description has been requested from ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")}. Depending on the speed of your provider, the new description may take some time to regenerate.`, + t("details.item.toast.success.regenerate", { + provider: capitalizeAll( + config?.genai.provider.replaceAll("_", " ") ?? + t("generativeAI"), + ), + }), { position: "top-center", duration: 7000, @@ -448,12 +462,18 @@ function ObjectDetailsTab({ error.response?.data?.detail || "Unknown error"; toast.error( - `Failed to call ${capitalizeAll(config?.genai.provider.replaceAll("_", " ") ?? "Generative AI")} for a new description: ${errorMessage}`, + t("details.item.toast.error.regenerate", { + provider: capitalizeAll( + config?.genai.provider.replaceAll("_", " ") ?? + t("generativeAI"), + ), + errorMessage, + }), { position: "top-center" }, ); }); }, - [search, config], + [search, config, t], ); const handleSubLabelSave = useCallback( @@ -472,7 +492,7 @@ function ObjectDetailsTab({ }) .then((response) => { if (response.status === 200) { - toast.success("Successfully updated sub label.", { + toast.success(t("details.item.toast.success.updatedSublabel"), { position: "top-center", }); @@ -520,12 +540,17 @@ function ObjectDetailsTab({ error.response?.data?.message || error.response?.data?.detail || "Unknown error"; - toast.error(`Failed to update sub label: ${errorMessage}`, { - position: "top-center", - }); + toast.error( + t("details.item.toast.error.updatedSublabelFailed", { + errorMessage, + }), + { + position: "top-center", + }, + ); }); }, - [search, apiHost, mutate, setSearch], + [search, apiHost, mutate, setSearch, t], ); return ( @@ -533,10 +558,10 @@ function ObjectDetailsTab({
    -
    Label
    +
    {t("details.label")}
    {getIconForLabel(search.label, "size-4 text-primary")} - {search.label} + {t("{search.label}", { ns: "objects" })} {search.sub_label && ` (${search.sub_label})`} @@ -550,7 +575,7 @@ function ObjectDetailsTab({ - Edit sub label + {t("details.editSubLable")}
    @@ -572,7 +597,7 @@ function ObjectDetailsTab({
    - Top Score + {t("details.topScore")}
    @@ -581,9 +606,7 @@ function ObjectDetailsTab({
    - 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. + {t("details.topScore.info")}
    @@ -594,12 +617,16 @@ function ObjectDetailsTab({
    {averageEstimatedSpeed && (
    -
    Estimated Speed
    +
    + {t("details.estimatedSpeed")} +
    {averageEstimatedSpeed && (
    {averageEstimatedSpeed}{" "} - {config?.ui.unit_system == "imperial" ? "mph" : "kph"}{" "} + {config?.ui.unit_system == "imperial" + ? t("unit.speed.mph", { ns: "common" }) + : t("unit.speed.kph", { ns: "common" })}{" "} {velocityAngle != undefined && ( )}
    -
    Camera
    +
    {t("details.camera")}
    {search.camera.replaceAll("_", " ")}
    -
    Timestamp
    +
    + {t("details.timestamp")} +
    {formattedDate}
    @@ -642,7 +671,7 @@ function ObjectDetailsTab({ /> {config?.semantic_search.enabled && search.data.type == "object" && ( )}
    @@ -673,18 +702,15 @@ function ObjectDetailsTab({
    -
    - Frigate will not request a description from your Generative AI - provider until the tracked object's lifecycle has ended. -
    +
    {t("details.description.aiTips")}
    ) : ( <> -
    Description
    +