mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-30 20:04:54 +03:00
Compare commits
16 Commits
ab43a33d19
...
cf9a4a9407
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf9a4a9407 | ||
|
|
06e5852743 | ||
|
|
b0f9fefd97 | ||
|
|
c2203ead61 | ||
|
|
1918e9682b | ||
|
|
fefb264e55 | ||
|
|
63b2384d87 | ||
|
|
d76335096a | ||
|
|
ab3ded38e6 | ||
|
|
2376bcaf97 | ||
|
|
6a27c47808 | ||
|
|
7703cfbfee | ||
|
|
25e8c2a051 | ||
|
|
ef19332fe5 | ||
|
|
530b69b877 | ||
|
|
a15399fed5 |
@ -5,7 +5,7 @@ title: Enrichments
|
|||||||
|
|
||||||
# Enrichments
|
# Enrichments
|
||||||
|
|
||||||
Some of Frigate's enrichments can use a discrete GPU / NPU for accelerated processing.
|
Some of Frigate's enrichments can use a discrete GPU or integrated GPU for accelerated processing.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@ -18,8 +18,10 @@ Object detection and enrichments (like Semantic Search, Face Recognition, and Li
|
|||||||
- **Intel**
|
- **Intel**
|
||||||
|
|
||||||
- OpenVINO will automatically be detected and used for enrichments in the default Frigate image.
|
- OpenVINO will automatically be detected and used for enrichments in the default Frigate image.
|
||||||
|
- **Note:** Intel NPUs have limited model support for enrichments. GPU is recommended for enrichments when available.
|
||||||
|
|
||||||
- **Nvidia**
|
- **Nvidia**
|
||||||
|
|
||||||
- Nvidia GPUs will automatically be detected and used for enrichments in the `-tensorrt` Frigate image.
|
- Nvidia GPUs will automatically be detected and used for enrichments in the `-tensorrt` Frigate image.
|
||||||
- Jetson devices will automatically be detected and used for enrichments in the `-tensorrt-jp6` Frigate image.
|
- Jetson devices will automatically be detected and used for enrichments in the `-tensorrt-jp6` Frigate image.
|
||||||
|
|
||||||
|
|||||||
@ -261,6 +261,8 @@ OpenVINO is supported on 6th Gen Intel platforms (Skylake) and newer. It will al
|
|||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
|
|
||||||
|
**NPU + GPU Systems:** If you have both NPU and GPU available (Intel Core Ultra processors), use NPU for object detection and GPU for enrichments (semantic search, face recognition, etc.) for best performance and compatibility.
|
||||||
|
|
||||||
When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming GPU resources are available. An example configuration would be:
|
When using many cameras one detector may not be enough to keep up. Multiple detectors can be defined assuming GPU resources are available. An example configuration would be:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@ -283,7 +285,7 @@ detectors:
|
|||||||
| [RF-DETR](#rf-detr) | ✅ | ✅ | Requires XE iGPU or Arc |
|
| [RF-DETR](#rf-detr) | ✅ | ✅ | Requires XE iGPU or Arc |
|
||||||
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
|
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
|
||||||
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
|
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
|
||||||
| [YOLOX](#yolox) | ✅ | ? | |
|
| [YOLOX](#yolox) | ✅ | ? | |
|
||||||
| [D-FINE](#d-fine) | ❌ | ❌ | |
|
| [D-FINE](#d-fine) | ❌ | ❌ | |
|
||||||
|
|
||||||
#### SSDLite MobileNet v2
|
#### SSDLite MobileNet v2
|
||||||
|
|||||||
@ -810,6 +810,8 @@ cameras:
|
|||||||
# NOTE: This must be different than any camera names, but can match with another zone on another
|
# NOTE: This must be different than any camera names, but can match with another zone on another
|
||||||
# camera.
|
# camera.
|
||||||
front_steps:
|
front_steps:
|
||||||
|
# Optional: A friendly name or descriptive text for the zones
|
||||||
|
friendly_name: ""
|
||||||
# Required: List of x,y coordinates to define the polygon of the zone.
|
# Required: List of x,y coordinates to define the polygon of the zone.
|
||||||
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
|
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
|
||||||
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428
|
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428
|
||||||
|
|||||||
@ -78,7 +78,7 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
|
|||||||
|
|
||||||
### GPU Acceleration
|
### GPU Acceleration
|
||||||
|
|
||||||
The CLIP models are downloaded in ONNX format, and the `large` model can be accelerated using GPU / NPU hardware, when available. This depends on the Docker build that is used. You can also target a specific device in a multi-GPU installation.
|
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. You can also target a specific device in a multi-GPU installation.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
semantic_search:
|
semantic_search:
|
||||||
@ -90,7 +90,7 @@ semantic_search:
|
|||||||
|
|
||||||
:::info
|
:::info
|
||||||
|
|
||||||
If the correct build is used for your GPU / NPU and the `large` model is configured, then the GPU / NPU will be detected and used automatically.
|
If the correct build is used for your GPU / NPU and the `large` model is configured, then the GPU will be detected and used automatically.
|
||||||
Specify the `device` option to target a specific GPU in a multi-GPU system (see [onnxruntime's provider options](https://onnxruntime.ai/docs/execution-providers/)).
|
Specify the `device` option to target a specific GPU in a multi-GPU system (see [onnxruntime's provider options](https://onnxruntime.ai/docs/execution-providers/)).
|
||||||
If you do not specify a device, the first available GPU will be used.
|
If you do not specify a device, the first available GPU will be used.
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ cameras:
|
|||||||
- entire_yard
|
- entire_yard
|
||||||
zones:
|
zones:
|
||||||
entire_yard:
|
entire_yard:
|
||||||
|
friendly_name: Entire yard # You can use characters from any language text
|
||||||
coordinates: ...
|
coordinates: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -44,8 +45,10 @@ cameras:
|
|||||||
- edge_yard
|
- edge_yard
|
||||||
zones:
|
zones:
|
||||||
edge_yard:
|
edge_yard:
|
||||||
|
friendly_name: Edge yard # You can use characters from any language text
|
||||||
coordinates: ...
|
coordinates: ...
|
||||||
inner_yard:
|
inner_yard:
|
||||||
|
friendly_name: Inner yard # You can use characters from any language text
|
||||||
coordinates: ...
|
coordinates: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -59,6 +62,7 @@ cameras:
|
|||||||
- entire_yard
|
- entire_yard
|
||||||
zones:
|
zones:
|
||||||
entire_yard:
|
entire_yard:
|
||||||
|
friendly_name: Entire yard
|
||||||
coordinates: ...
|
coordinates: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -82,6 +86,7 @@ cameras:
|
|||||||
|
|
||||||
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street.
|
Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. Objects will be tracked for any `person` that enter anywhere in the yard, and for cars only if they enter the street.
|
||||||
|
|
||||||
|
|
||||||
### Zone Loitering
|
### Zone Loitering
|
||||||
|
|
||||||
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone.
|
Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time after which the object will be considered in the zone.
|
||||||
|
|||||||
@ -662,8 +662,11 @@ def delete_classification_dataset_images(
|
|||||||
if os.path.isfile(file_path):
|
if os.path.isfile(file_path):
|
||||||
os.unlink(file_path)
|
os.unlink(file_path)
|
||||||
|
|
||||||
|
if os.path.exists(folder) and not os.listdir(folder):
|
||||||
|
os.rmdir(folder)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
content=({"success": True, "message": "Successfully deleted images."}),
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -723,7 +726,7 @@ def categorize_classification_image(request: Request, name: str, body: dict = No
|
|||||||
os.unlink(training_file)
|
os.unlink(training_file)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
content=({"success": True, "message": "Successfully categorized image."}),
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -761,7 +764,7 @@ def delete_classification_train_images(request: Request, name: str, body: dict =
|
|||||||
os.unlink(file_path)
|
os.unlink(file_path)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content=({"success": True, "message": "Successfully deleted faces."}),
|
content=({"success": True, "message": "Successfully deleted images."}),
|
||||||
status_code=200,
|
status_code=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class ZoneConfig(BaseModel):
|
class ZoneConfig(BaseModel):
|
||||||
|
friendly_name: Optional[str] = Field(
|
||||||
|
None, title="Zone friendly name used in the Frigate UI."
|
||||||
|
)
|
||||||
filters: dict[str, FilterConfig] = Field(
|
filters: dict[str, FilterConfig] = Field(
|
||||||
default_factory=dict, title="Zone filters."
|
default_factory=dict, title="Zone filters."
|
||||||
)
|
)
|
||||||
|
|||||||
@ -9,7 +9,6 @@ from typing import Optional
|
|||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
from peewee import DoesNotExist
|
from peewee import DoesNotExist
|
||||||
|
|
||||||
from frigate.comms.embeddings_updater import EmbeddingsRequestEnum
|
|
||||||
from frigate.comms.inter_process import InterProcessRequestor
|
from frigate.comms.inter_process import InterProcessRequestor
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
@ -32,11 +31,13 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
|||||||
self,
|
self,
|
||||||
config: FrigateConfig,
|
config: FrigateConfig,
|
||||||
requestor: InterProcessRequestor,
|
requestor: InterProcessRequestor,
|
||||||
|
embeddings,
|
||||||
metrics: DataProcessorMetrics,
|
metrics: DataProcessorMetrics,
|
||||||
):
|
):
|
||||||
super().__init__(config, metrics, None)
|
super().__init__(config, metrics, None)
|
||||||
self.config = config
|
self.config = config
|
||||||
self.requestor = requestor
|
self.requestor = requestor
|
||||||
|
self.embeddings = embeddings
|
||||||
self.recognizer = None
|
self.recognizer = None
|
||||||
self.transcription_lock = threading.Lock()
|
self.transcription_lock = threading.Lock()
|
||||||
self.transcription_thread = None
|
self.transcription_thread = None
|
||||||
@ -128,10 +129,7 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Embed the description
|
# Embed the description
|
||||||
self.requestor.send_data(
|
self.embeddings.embed_description(event_id, transcription)
|
||||||
EmbeddingsRequestEnum.embed_description.value,
|
|
||||||
{"id": event_id, "description": transcription},
|
|
||||||
)
|
|
||||||
|
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
logger.debug("No recording found for audio transcription post-processing")
|
logger.debug("No recording found for audio transcription post-processing")
|
||||||
|
|||||||
@ -418,8 +418,8 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
|||||||
obj_data["box"][2],
|
obj_data["box"][2],
|
||||||
obj_data["box"][3],
|
obj_data["box"][3],
|
||||||
max(
|
max(
|
||||||
obj_data["box"][1] - obj_data["box"][0],
|
obj_data["box"][2] - obj_data["box"][0],
|
||||||
obj_data["box"][3] - obj_data["box"][2],
|
obj_data["box"][3] - obj_data["box"][1],
|
||||||
),
|
),
|
||||||
1.0,
|
1.0,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import threading
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -161,12 +162,12 @@ class CudaGraphRunner(BaseModelRunner):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_complex_model(model_type: str) -> bool:
|
def is_model_supported(model_type: str) -> bool:
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from frigate.detectors.detector_config import ModelTypeEnum
|
from frigate.detectors.detector_config import ModelTypeEnum
|
||||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||||
|
|
||||||
return model_type in [
|
return model_type not in [
|
||||||
ModelTypeEnum.yolonas.value,
|
ModelTypeEnum.yolonas.value,
|
||||||
EnrichmentModelTypeEnum.paddleocr.value,
|
EnrichmentModelTypeEnum.paddleocr.value,
|
||||||
EnrichmentModelTypeEnum.jina_v1.value,
|
EnrichmentModelTypeEnum.jina_v1.value,
|
||||||
@ -239,9 +240,30 @@ class OpenVINOModelRunner(BaseModelRunner):
|
|||||||
EnrichmentModelTypeEnum.jina_v2.value,
|
EnrichmentModelTypeEnum.jina_v2.value,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_model_npu_supported(model_type: str) -> bool:
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||||
|
|
||||||
|
return model_type not in [
|
||||||
|
EnrichmentModelTypeEnum.paddleocr.value,
|
||||||
|
EnrichmentModelTypeEnum.jina_v1.value,
|
||||||
|
EnrichmentModelTypeEnum.jina_v2.value,
|
||||||
|
EnrichmentModelTypeEnum.arcface.value,
|
||||||
|
]
|
||||||
|
|
||||||
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
|
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
|
||||||
self.model_path = model_path
|
self.model_path = model_path
|
||||||
self.device = device
|
self.device = device
|
||||||
|
|
||||||
|
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
|
||||||
|
model_type
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
f"OpenVINO model {model_type} is not supported on NPU, using GPU instead"
|
||||||
|
)
|
||||||
|
device = "GPU"
|
||||||
|
|
||||||
self.complex_model = OpenVINOModelRunner.is_complex_model(model_type)
|
self.complex_model = OpenVINOModelRunner.is_complex_model(model_type)
|
||||||
|
|
||||||
if not os.path.isfile(model_path):
|
if not os.path.isfile(model_path):
|
||||||
@ -269,6 +291,10 @@ class OpenVINOModelRunner(BaseModelRunner):
|
|||||||
self.infer_request = self.compiled_model.create_infer_request()
|
self.infer_request = self.compiled_model.create_infer_request()
|
||||||
self.input_tensor: ov.Tensor | None = None
|
self.input_tensor: ov.Tensor | None = None
|
||||||
|
|
||||||
|
# Thread lock to prevent concurrent inference (needed for JinaV2 which shares
|
||||||
|
# one runner between text and vision embeddings called from different threads)
|
||||||
|
self._inference_lock = threading.Lock()
|
||||||
|
|
||||||
if not self.complex_model:
|
if not self.complex_model:
|
||||||
try:
|
try:
|
||||||
input_shape = self.compiled_model.inputs[0].get_shape()
|
input_shape = self.compiled_model.inputs[0].get_shape()
|
||||||
@ -312,67 +338,70 @@ class OpenVINOModelRunner(BaseModelRunner):
|
|||||||
Returns:
|
Returns:
|
||||||
List of output tensors
|
List of output tensors
|
||||||
"""
|
"""
|
||||||
# Handle single input case for backward compatibility
|
# Lock prevents concurrent access to infer_request
|
||||||
if (
|
# Needed for JinaV2: genai thread (text) + embeddings thread (vision)
|
||||||
len(inputs) == 1
|
with self._inference_lock:
|
||||||
and len(self.compiled_model.inputs) == 1
|
# Handle single input case for backward compatibility
|
||||||
and self.input_tensor is not None
|
if (
|
||||||
):
|
len(inputs) == 1
|
||||||
# Single input case - use the pre-allocated tensor for efficiency
|
and len(self.compiled_model.inputs) == 1
|
||||||
input_data = list(inputs.values())[0]
|
and self.input_tensor is not None
|
||||||
np.copyto(self.input_tensor.data, input_data)
|
):
|
||||||
self.infer_request.infer(self.input_tensor)
|
# Single input case - use the pre-allocated tensor for efficiency
|
||||||
else:
|
input_data = list(inputs.values())[0]
|
||||||
if self.complex_model:
|
np.copyto(self.input_tensor.data, input_data)
|
||||||
try:
|
self.infer_request.infer(self.input_tensor)
|
||||||
# This ensures the model starts with a clean state for each sequence
|
else:
|
||||||
# Important for RNN models like PaddleOCR recognition
|
if self.complex_model:
|
||||||
self.infer_request.reset_state()
|
try:
|
||||||
except Exception:
|
# This ensures the model starts with a clean state for each sequence
|
||||||
# this will raise an exception for models with AUTO set as the device
|
# Important for RNN models like PaddleOCR recognition
|
||||||
pass
|
self.infer_request.reset_state()
|
||||||
|
except Exception:
|
||||||
|
# this will raise an exception for models with AUTO set as the device
|
||||||
|
pass
|
||||||
|
|
||||||
# Multiple inputs case - set each input by name
|
# Multiple inputs case - set each input by name
|
||||||
for input_name, input_data in inputs.items():
|
for input_name, input_data in inputs.items():
|
||||||
# Find the input by name and its index
|
# Find the input by name and its index
|
||||||
input_port = None
|
input_port = None
|
||||||
input_index = None
|
input_index = None
|
||||||
for idx, port in enumerate(self.compiled_model.inputs):
|
for idx, port in enumerate(self.compiled_model.inputs):
|
||||||
if port.get_any_name() == input_name:
|
if port.get_any_name() == input_name:
|
||||||
input_port = port
|
input_port = port
|
||||||
input_index = idx
|
input_index = idx
|
||||||
break
|
break
|
||||||
|
|
||||||
if input_port is None:
|
if input_port is None:
|
||||||
raise ValueError(f"Input '{input_name}' not found in model")
|
raise ValueError(f"Input '{input_name}' not found in model")
|
||||||
|
|
||||||
# Create tensor with the correct element type
|
# Create tensor with the correct element type
|
||||||
input_element_type = input_port.get_element_type()
|
input_element_type = input_port.get_element_type()
|
||||||
|
|
||||||
# Ensure input data matches the expected dtype to prevent type mismatches
|
# Ensure input data matches the expected dtype to prevent type mismatches
|
||||||
# that can occur with models like Jina-CLIP v2 running on OpenVINO
|
# that can occur with models like Jina-CLIP v2 running on OpenVINO
|
||||||
expected_dtype = input_element_type.to_dtype()
|
expected_dtype = input_element_type.to_dtype()
|
||||||
if input_data.dtype != expected_dtype:
|
if input_data.dtype != expected_dtype:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Converting input '{input_name}' from {input_data.dtype} to {expected_dtype}"
|
f"Converting input '{input_name}' from {input_data.dtype} to {expected_dtype}"
|
||||||
)
|
)
|
||||||
input_data = input_data.astype(expected_dtype)
|
input_data = input_data.astype(expected_dtype)
|
||||||
|
|
||||||
input_tensor = ov.Tensor(input_element_type, input_data.shape)
|
input_tensor = ov.Tensor(input_element_type, input_data.shape)
|
||||||
np.copyto(input_tensor.data, input_data)
|
np.copyto(input_tensor.data, input_data)
|
||||||
|
|
||||||
# Set the input tensor for the specific port index
|
# Set the input tensor for the specific port index
|
||||||
self.infer_request.set_input_tensor(input_index, input_tensor)
|
self.infer_request.set_input_tensor(input_index, input_tensor)
|
||||||
|
|
||||||
# Run inference
|
# Run inference
|
||||||
self.infer_request.infer()
|
self.infer_request.infer()
|
||||||
|
|
||||||
# Get all output tensors
|
# Get all output tensors
|
||||||
outputs = []
|
outputs = []
|
||||||
for i in range(len(self.compiled_model.outputs)):
|
for i in range(len(self.compiled_model.outputs)):
|
||||||
outputs.append(self.infer_request.get_output_tensor(i).data)
|
outputs.append(self.infer_request.get_output_tensor(i).data)
|
||||||
|
|
||||||
return outputs
|
return outputs
|
||||||
|
|
||||||
|
|
||||||
class RKNNModelRunner(BaseModelRunner):
|
class RKNNModelRunner(BaseModelRunner):
|
||||||
@ -500,7 +529,7 @@ def get_optimized_runner(
|
|||||||
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
|
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not CudaGraphRunner.is_complex_model(model_type)
|
not CudaGraphRunner.is_model_supported(model_type)
|
||||||
and providers[0] == "CUDAExecutionProvider"
|
and providers[0] == "CUDAExecutionProvider"
|
||||||
):
|
):
|
||||||
options[0] = {
|
options[0] = {
|
||||||
|
|||||||
@ -226,7 +226,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
for c in self.config.cameras.values()
|
for c in self.config.cameras.values()
|
||||||
):
|
):
|
||||||
self.post_processors.append(
|
self.post_processors.append(
|
||||||
AudioTranscriptionPostProcessor(self.config, self.requestor, metrics)
|
AudioTranscriptionPostProcessor(
|
||||||
|
self.config, self.requestor, self.embeddings, metrics
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
semantic_trigger_processor: SemanticTriggerProcessor | None = None
|
semantic_trigger_processor: SemanticTriggerProcessor | None = None
|
||||||
|
|||||||
@ -369,6 +369,10 @@ def get_ort_providers(
|
|||||||
"enable_cpu_mem_arena": False,
|
"enable_cpu_mem_arena": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
elif provider == "AzureExecutionProvider":
|
||||||
|
# Skip Azure provider - not typically available on local hardware
|
||||||
|
# and prevents fallback to OpenVINO when it's the first provider
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
providers.append(provider)
|
providers.append(provider)
|
||||||
options.append({})
|
options.append({})
|
||||||
|
|||||||
@ -40,7 +40,8 @@
|
|||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
"title": "Delete Classification Model",
|
"title": "Delete Classification Model",
|
||||||
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
|
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
|
||||||
"desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
|
"desc_one": "Are you sure you want to delete {{count}} model? This will permanently delete all associated data including images and training data. This action cannot be undone.",
|
||||||
|
"desc_other": "Are you sure you want to delete {{count}} models? This will permanently delete all associated data including images and training data. This action cannot be undone."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Edit Classification Model",
|
"title": "Edit Classification Model",
|
||||||
@ -50,11 +51,13 @@
|
|||||||
},
|
},
|
||||||
"deleteDatasetImages": {
|
"deleteDatasetImages": {
|
||||||
"title": "Delete Dataset Images",
|
"title": "Delete Dataset Images",
|
||||||
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
|
"desc_one": "Are you sure you want to delete {{count}} image from {{dataset}}? This action cannot be undone and will require re-training the model.",
|
||||||
|
"desc_other": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
|
||||||
},
|
},
|
||||||
"deleteTrainImages": {
|
"deleteTrainImages": {
|
||||||
"title": "Delete Train Images",
|
"title": "Delete Train Images",
|
||||||
"desc": "Are you sure you want to delete {{count}} images? This action cannot be undone."
|
"desc_one": "Are you sure you want to delete {{count}} image? This action cannot be undone.",
|
||||||
|
"desc_other": "Are you sure you want to delete {{count}} images? This action cannot be undone."
|
||||||
},
|
},
|
||||||
"renameCategory": {
|
"renameCategory": {
|
||||||
"title": "Rename Class",
|
"title": "Rename Class",
|
||||||
|
|||||||
@ -2,12 +2,19 @@ import * as React from "react";
|
|||||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
import { CameraConfig } from "@/types/frigateConfig";
|
import { CameraConfig } from "@/types/frigateConfig";
|
||||||
|
import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name";
|
||||||
|
|
||||||
interface CameraNameLabelProps
|
interface CameraNameLabelProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||||
camera?: string | CameraConfig;
|
camera?: string | CameraConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ZoneNameLabelProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||||
|
zone: string;
|
||||||
|
camera?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CameraNameLabel = React.forwardRef<
|
const CameraNameLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
CameraNameLabelProps
|
CameraNameLabelProps
|
||||||
@ -21,4 +28,17 @@ const CameraNameLabel = React.forwardRef<
|
|||||||
});
|
});
|
||||||
CameraNameLabel.displayName = LabelPrimitive.Root.displayName;
|
CameraNameLabel.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
export { CameraNameLabel };
|
const ZoneNameLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
ZoneNameLabelProps
|
||||||
|
>(({ className, zone, camera, ...props }, ref) => {
|
||||||
|
const displayName = useZoneFriendlyName(zone, camera);
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={className} {...props}>
|
||||||
|
{displayName}
|
||||||
|
</LabelPrimitive.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ZoneNameLabel.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { CameraNameLabel, ZoneNameLabel };
|
||||||
@ -14,7 +14,6 @@ type SearchThumbnailProps = {
|
|||||||
findSimilar: () => void;
|
findSimilar: () => void;
|
||||||
refreshResults: () => void;
|
refreshResults: () => void;
|
||||||
showTrackingDetails: () => void;
|
showTrackingDetails: () => void;
|
||||||
showSnapshot: () => void;
|
|
||||||
addTrigger: () => void;
|
addTrigger: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -24,7 +23,6 @@ export default function SearchThumbnailFooter({
|
|||||||
findSimilar,
|
findSimilar,
|
||||||
refreshResults,
|
refreshResults,
|
||||||
showTrackingDetails,
|
showTrackingDetails,
|
||||||
showSnapshot,
|
|
||||||
addTrigger,
|
addTrigger,
|
||||||
}: SearchThumbnailProps) {
|
}: SearchThumbnailProps) {
|
||||||
const { t } = useTranslation(["views/search"]);
|
const { t } = useTranslation(["views/search"]);
|
||||||
@ -62,7 +60,6 @@ export default function SearchThumbnailFooter({
|
|||||||
findSimilar={findSimilar}
|
findSimilar={findSimilar}
|
||||||
refreshResults={refreshResults}
|
refreshResults={refreshResults}
|
||||||
showTrackingDetails={showTrackingDetails}
|
showTrackingDetails={showTrackingDetails}
|
||||||
showSnapshot={showSnapshot}
|
|
||||||
addTrigger={addTrigger}
|
addTrigger={addTrigger}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -76,7 +76,7 @@ import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
|
|||||||
import { DialogTrigger } from "@radix-ui/react-dialog";
|
import { DialogTrigger } from "@radix-ui/react-dialog";
|
||||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||||
|
|
||||||
|
|||||||
@ -190,7 +190,7 @@ export function CamerasFilterContent({
|
|||||||
key={item}
|
key={item}
|
||||||
isChecked={currentCameras?.includes(item) ?? false}
|
isChecked={currentCameras?.includes(item) ?? false}
|
||||||
label={item}
|
label={item}
|
||||||
isCameraName={true}
|
type={"camera"}
|
||||||
disabled={
|
disabled={
|
||||||
mainCamera !== undefined &&
|
mainCamera !== undefined &&
|
||||||
currentCameras !== undefined &&
|
currentCameras !== undefined &&
|
||||||
|
|||||||
@ -1,29 +1,39 @@
|
|||||||
import { Switch } from "../ui/switch";
|
import { Switch } from "../ui/switch";
|
||||||
import { Label } from "../ui/label";
|
import { Label } from "../ui/label";
|
||||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type FilterSwitchProps = {
|
type FilterSwitchProps = {
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
isCameraName?: boolean;
|
isCameraName?: boolean;
|
||||||
|
type?: string;
|
||||||
|
extraValue?: string;
|
||||||
onCheckedChange: (checked: boolean) => void;
|
onCheckedChange: (checked: boolean) => void;
|
||||||
};
|
};
|
||||||
export default function FilterSwitch({
|
export default function FilterSwitch({
|
||||||
label,
|
label,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isChecked,
|
isChecked,
|
||||||
isCameraName = false,
|
type = "",
|
||||||
|
extraValue = "",
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
}: FilterSwitchProps) {
|
}: FilterSwitchProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-1">
|
||||||
{isCameraName ? (
|
{type === "camera" ? (
|
||||||
<CameraNameLabel
|
<CameraNameLabel
|
||||||
className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`}
|
className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`}
|
||||||
htmlFor={label}
|
htmlFor={label}
|
||||||
camera={label}
|
camera={label}
|
||||||
/>
|
/>
|
||||||
|
) : type === "zone" ? (
|
||||||
|
<ZoneNameLabel
|
||||||
|
className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`}
|
||||||
|
htmlFor={label}
|
||||||
|
camera={extraValue}
|
||||||
|
zone={label}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Label
|
<Label
|
||||||
className={`mx-2 w-full cursor-pointer text-primary smart-capitalize ${disabled ? "text-secondary-foreground" : ""}`}
|
className={`mx-2 w-full cursor-pointer text-primary smart-capitalize ${disabled ? "text-secondary-foreground" : ""}`}
|
||||||
|
|||||||
@ -550,7 +550,8 @@ export function GeneralFilterContent({
|
|||||||
{allZones.map((item) => (
|
{allZones.map((item) => (
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
key={item}
|
key={item}
|
||||||
label={item.replaceAll("_", " ")}
|
label={item}
|
||||||
|
type={"zone"}
|
||||||
isChecked={filter.zones?.includes(item) ?? false}
|
isChecked={filter.zones?.includes(item) ?? false}
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
|
|||||||
@ -53,7 +53,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { MdImageSearch } from "react-icons/md";
|
import { MdImageSearch } from "react-icons/md";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type InputWithTagsProps = {
|
type InputWithTagsProps = {
|
||||||
inputFocused: boolean;
|
inputFocused: boolean;
|
||||||
@ -831,6 +831,8 @@ export default function InputWithTags({
|
|||||||
getTranslatedLabel(value)
|
getTranslatedLabel(value)
|
||||||
) : filterType === "cameras" ? (
|
) : filterType === "cameras" ? (
|
||||||
<CameraNameLabel camera={value} />
|
<CameraNameLabel camera={value} />
|
||||||
|
) : filterType === "zones" ? (
|
||||||
|
<ZoneNameLabel zone={value} />
|
||||||
) : (
|
) : (
|
||||||
value.replaceAll("_", " ")
|
value.replaceAll("_", " ")
|
||||||
)}
|
)}
|
||||||
@ -934,6 +936,11 @@ export default function InputWithTags({
|
|||||||
<CameraNameLabel camera={suggestion} />
|
<CameraNameLabel camera={suggestion} />
|
||||||
{")"}
|
{")"}
|
||||||
</>
|
</>
|
||||||
|
) : currentFilterType === "zones" ? (
|
||||||
|
<>
|
||||||
|
{suggestion} {" ("} <ZoneNameLabel zone={suggestion} />
|
||||||
|
{")"}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
suggestion
|
suggestion
|
||||||
)
|
)
|
||||||
@ -943,6 +950,8 @@ export default function InputWithTags({
|
|||||||
{currentFilterType ? (
|
{currentFilterType ? (
|
||||||
currentFilterType === "cameras" ? (
|
currentFilterType === "cameras" ? (
|
||||||
<CameraNameLabel camera={suggestion} />
|
<CameraNameLabel camera={suggestion} />
|
||||||
|
) : currentFilterType === "zones" ? (
|
||||||
|
<ZoneNameLabel zone={suggestion} />
|
||||||
) : (
|
) : (
|
||||||
formatFilterValues(currentFilterType, suggestion)
|
formatFilterValues(currentFilterType, suggestion)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -47,7 +47,7 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type LiveContextMenuProps = {
|
type LiveContextMenuProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@ -6,10 +6,7 @@ import { toast } from "sonner";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
|
import { LuCamera, LuDownload, LuTrash2 } from "react-icons/lu";
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import { FaArrowsRotate } from "react-icons/fa6";
|
|
||||||
import { MdImageSearch } from "react-icons/md";
|
import { MdImageSearch } from "react-icons/md";
|
||||||
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
|
|
||||||
import { isMobileOnly } from "react-device-detect";
|
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@ -33,23 +30,18 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { BsFillLightningFill } from "react-icons/bs";
|
import { BsFillLightningFill } from "react-icons/bs";
|
||||||
import BlurredIconButton from "../button/BlurredIconButton";
|
import BlurredIconButton from "../button/BlurredIconButton";
|
||||||
|
import { PiPath } from "react-icons/pi";
|
||||||
|
|
||||||
type SearchResultActionsProps = {
|
type SearchResultActionsProps = {
|
||||||
searchResult: SearchResult;
|
searchResult: SearchResult;
|
||||||
findSimilar: () => void;
|
findSimilar: () => void;
|
||||||
refreshResults: () => void;
|
refreshResults: () => void;
|
||||||
showTrackingDetails: () => void;
|
showTrackingDetails: () => void;
|
||||||
showSnapshot: () => void;
|
|
||||||
addTrigger: () => void;
|
addTrigger: () => void;
|
||||||
isContextMenu?: boolean;
|
isContextMenu?: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@ -60,7 +52,6 @@ export default function SearchResultActions({
|
|||||||
findSimilar,
|
findSimilar,
|
||||||
refreshResults,
|
refreshResults,
|
||||||
showTrackingDetails,
|
showTrackingDetails,
|
||||||
showSnapshot,
|
|
||||||
addTrigger,
|
addTrigger,
|
||||||
isContextMenu = false,
|
isContextMenu = false,
|
||||||
children,
|
children,
|
||||||
@ -129,7 +120,7 @@ export default function SearchResultActions({
|
|||||||
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
||||||
onClick={showTrackingDetails}
|
onClick={showTrackingDetails}
|
||||||
>
|
>
|
||||||
<FaArrowsRotate className="mr-2 size-4" />
|
<PiPath className="mr-2 size-4" />
|
||||||
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
<span>{t("itemMenu.viewTrackingDetails.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
@ -152,18 +143,14 @@ export default function SearchResultActions({
|
|||||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{isMobileOnly &&
|
{config?.semantic_search?.enabled &&
|
||||||
config?.plus?.enabled &&
|
searchResult.data.type == "object" && (
|
||||||
searchResult.has_snapshot &&
|
|
||||||
searchResult.end_time &&
|
|
||||||
searchResult.data.type == "object" &&
|
|
||||||
!searchResult.plus_id && (
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
aria-label={t("itemMenu.submitToPlus.aria")}
|
aria-label={t("itemMenu.findSimilar.aria")}
|
||||||
onClick={showSnapshot}
|
onClick={findSimilar}
|
||||||
>
|
>
|
||||||
<FrigatePlusIcon className="mr-2 size-4 cursor-pointer text-primary" />
|
<MdImageSearch className="mr-2 size-4" />
|
||||||
<span>{t("itemMenu.submitToPlus.label")}</span>
|
<span>{t("itemMenu.findSimilar.label")}</span>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@ -211,44 +198,6 @@ export default function SearchResultActions({
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{config?.semantic_search?.enabled &&
|
|
||||||
searchResult.data.type == "object" && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<BlurredIconButton
|
|
||||||
onClick={findSimilar}
|
|
||||||
aria-label={t("itemMenu.findSimilar.aria")}
|
|
||||||
>
|
|
||||||
<MdImageSearch className="size-5" />
|
|
||||||
</BlurredIconButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("itemMenu.findSimilar.label")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isMobileOnly &&
|
|
||||||
config?.plus?.enabled &&
|
|
||||||
searchResult.has_snapshot &&
|
|
||||||
searchResult.end_time &&
|
|
||||||
searchResult.data.type == "object" &&
|
|
||||||
!searchResult.plus_id && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<BlurredIconButton
|
|
||||||
onClick={showSnapshot}
|
|
||||||
aria-label={t("itemMenu.submitToPlus.aria")}
|
|
||||||
>
|
|
||||||
<FrigatePlusIcon className="size-5" />
|
|
||||||
</BlurredIconButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("itemMenu.submitToPlus.label")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<BlurredIconButton aria-label={t("itemMenu.more.aria")}>
|
<BlurredIconButton aria-label={t("itemMenu.more.aria")}>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
@ -121,17 +122,20 @@ export function MobilePagePortal({
|
|||||||
type MobilePageContentProps = {
|
type MobilePageContentProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
scrollerRef?: React.RefObject<HTMLDivElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MobilePageContent({
|
export function MobilePageContent({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
scrollerRef,
|
||||||
}: MobilePageContentProps) {
|
}: MobilePageContentProps) {
|
||||||
const context = useContext(MobilePageContext);
|
const context = useContext(MobilePageContext);
|
||||||
if (!context)
|
if (!context)
|
||||||
throw new Error("MobilePageContent must be used within MobilePage");
|
throw new Error("MobilePageContent must be used within MobilePage");
|
||||||
|
|
||||||
const [isVisible, setIsVisible] = useState(context.open);
|
const [isVisible, setIsVisible] = useState(context.open);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (context.open) {
|
if (context.open) {
|
||||||
@ -140,15 +144,27 @@ export function MobilePageContent({
|
|||||||
}, [context.open]);
|
}, [context.open]);
|
||||||
|
|
||||||
const handleAnimationComplete = () => {
|
const handleAnimationComplete = () => {
|
||||||
if (!context.open) {
|
if (context.open) {
|
||||||
|
// After opening animation completes, ensure scroller is at the top
|
||||||
|
if (scrollerRef?.current) {
|
||||||
|
scrollerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
setIsVisible(false);
|
setIsVisible(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (context.open && scrollerRef?.current) {
|
||||||
|
scrollerRef.current.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}, [context.open, scrollerRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 mb-12 bg-background",
|
"fixed inset-0 z-50 mb-12 bg-background",
|
||||||
isPWA && "mb-16",
|
isPWA && "mb-16",
|
||||||
|
|||||||
@ -97,14 +97,12 @@ export default function ClassificationSelectionDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className ?? "flex"}>
|
<div className={className ?? "flex"}>
|
||||||
{newClass && (
|
<TextEntryDialog
|
||||||
<TextEntryDialog
|
open={newClass}
|
||||||
open={true}
|
setOpen={setNewClass}
|
||||||
setOpen={setNewClass}
|
title={t("createCategory.new")}
|
||||||
title={t("createCategory.new")}
|
onSave={(newCat) => onCategorizeImage(newCat)}
|
||||||
onSave={(newCat) => onCategorizeImage(newCat)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Selector>
|
<Selector>
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type EditRoleCamerasOverlayProps = {
|
type EditRoleCamerasOverlayProps = {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Button } from "../ui/button";
|
|||||||
import { FaVideo } from "react-icons/fa";
|
import { FaVideo } from "react-icons/fa";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type MobileCameraDrawerProps = {
|
type MobileCameraDrawerProps = {
|
||||||
allCameras: string[];
|
allCameras: string[];
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Event } from "@/types/event";
|
import { Event } from "@/types/event";
|
||||||
|
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||||
|
|
||||||
// Use a small tolerance (10ms) for browsers with seek precision by-design issues
|
// Use a small tolerance (10ms) for browsers with seek precision by-design issues
|
||||||
const TOLERANCE = 0.01;
|
const TOLERANCE = 0.01;
|
||||||
@ -114,6 +115,10 @@ export default function ObjectTrackOverlay({
|
|||||||
{ revalidateOnFocus: false },
|
{ revalidateOnFocus: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
|
||||||
|
return zones?.map((zone) => resolveZoneName(config, zone)) ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
const timelineResults = useMemo(() => {
|
const timelineResults = useMemo(() => {
|
||||||
// Group timeline entries by source_id
|
// Group timeline entries by source_id
|
||||||
if (!timelineData) return selectedObjectIds.map(() => []);
|
if (!timelineData) return selectedObjectIds.map(() => []);
|
||||||
@ -127,8 +132,19 @@ export default function ObjectTrackOverlay({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return timeline arrays in the same order as selectedObjectIds
|
// Return timeline arrays in the same order as selectedObjectIds
|
||||||
return selectedObjectIds.map((id) => grouped[id] || []);
|
return selectedObjectIds.map((id) => {
|
||||||
}, [selectedObjectIds, timelineData]);
|
const entries = grouped[id] || [];
|
||||||
|
return entries.map((event) => ({
|
||||||
|
...event,
|
||||||
|
data: {
|
||||||
|
...event.data,
|
||||||
|
zones_friendly_names: config
|
||||||
|
? getZonesFriendlyNames(event.data?.zones, config)
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, [selectedObjectIds, timelineData, config]);
|
||||||
|
|
||||||
const typeColorMap = useMemo(
|
const typeColorMap = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@ -141,50 +141,52 @@ export function AnnotationSettingsPane({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="flex flex-1 flex-col space-y-6"
|
className="flex flex-1 flex-col space-y-3"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="annotationOffset"
|
name="annotationOffset"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-start justify-between space-x-2">
|
<>
|
||||||
<div className="flex flex-col gap-1">
|
<FormItem className="flex flex-row items-start justify-between space-x-2">
|
||||||
<FormLabel>
|
<div className="flex flex-col gap-1">
|
||||||
{t("trackingDetails.annotationSettings.offset.label")}
|
<FormLabel>
|
||||||
</FormLabel>
|
{t("trackingDetails.annotationSettings.offset.label")}
|
||||||
<FormDescription>
|
</FormLabel>
|
||||||
<Trans ns="views/explore">
|
<FormDescription>
|
||||||
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
<Trans ns="views/explore">
|
||||||
</Trans>
|
trackingDetails.annotationSettings.offset.millisecondsToOffset
|
||||||
<FormMessage />
|
</Trans>
|
||||||
<div className="mt-2">
|
<FormMessage />
|
||||||
{t("trackingDetails.annotationSettings.offset.tips")}
|
</FormDescription>
|
||||||
<div className="mt-2 flex items-center text-primary">
|
</div>
|
||||||
<Link
|
<div className="flex flex-col gap-3">
|
||||||
to={getLocaleDocUrl("configuration/reference")}
|
<div className="min-w-24">
|
||||||
target="_blank"
|
<FormControl>
|
||||||
rel="noopener noreferrer"
|
<Input
|
||||||
className="inline"
|
className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
||||||
>
|
placeholder="0"
|
||||||
{t("readTheDocumentation", { ns: "common" })}
|
{...field}
|
||||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
/>
|
||||||
</Link>
|
</FormControl>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</FormDescription>
|
</div>
|
||||||
</div>
|
</FormItem>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="mt-1 text-sm text-secondary-foreground">
|
||||||
<div className="min-w-24">
|
{t("trackingDetails.annotationSettings.offset.tips")}
|
||||||
<FormControl>
|
<div className="mt-2 flex items-center text-primary-variant">
|
||||||
<Input
|
<Link
|
||||||
className="text-md w-full border border-input bg-background p-2 text-center hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
to={getLocaleDocUrl("configuration/reference")}
|
||||||
placeholder="0"
|
target="_blank"
|
||||||
{...field}
|
rel="noopener noreferrer"
|
||||||
/>
|
className="inline"
|
||||||
</FormControl>
|
>
|
||||||
|
{t("readTheDocumentation", { ns: "common" })}
|
||||||
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -55,29 +55,32 @@ export default function DetailActionsMenu({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem>
|
{search.has_snapshot && (
|
||||||
<a
|
<DropdownMenuItem>
|
||||||
className="w-full"
|
<a
|
||||||
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
|
className="w-full"
|
||||||
download={`${search.camera}_${search.label}.jpg`}
|
href={`${baseUrl}api/events/${search.id}/snapshot.jpg?bbox=1`}
|
||||||
>
|
download={`${search.camera}_${search.label}.jpg`}
|
||||||
<div className="flex cursor-pointer items-center gap-2">
|
>
|
||||||
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
<div className="flex cursor-pointer items-center gap-2">
|
||||||
</div>
|
<span>{t("itemMenu.downloadSnapshot.label")}</span>
|
||||||
</a>
|
</div>
|
||||||
</DropdownMenuItem>
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
)}
|
||||||
<a
|
{search.has_clip && (
|
||||||
className="w-full"
|
<DropdownMenuItem>
|
||||||
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
|
<a
|
||||||
download
|
className="w-full"
|
||||||
>
|
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
|
||||||
<div className="flex cursor-pointer items-center gap-2">
|
download
|
||||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
>
|
||||||
</div>
|
<div className="flex cursor-pointer items-center gap-2">
|
||||||
</a>
|
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{config?.semantic_search.enabled &&
|
{config?.semantic_search.enabled &&
|
||||||
setSimilarity != undefined &&
|
setSimilarity != undefined &&
|
||||||
@ -111,6 +114,23 @@ export default function DetailActionsMenu({
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{config?.semantic_search.enabled && search.data.type == "object" && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate(
|
||||||
|
`/settings?page=triggers&camera=${search.camera}&event_id=${search.id}`,
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex cursor-pointer items-center gap-2">
|
||||||
|
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -8,6 +8,9 @@ import {
|
|||||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
type ObjectPathProps = {
|
type ObjectPathProps = {
|
||||||
positions?: Position[];
|
positions?: Position[];
|
||||||
@ -42,16 +45,31 @@ export function ObjectPath({
|
|||||||
visible = true,
|
visible = true,
|
||||||
}: ObjectPathProps) {
|
}: ObjectPathProps) {
|
||||||
const { t } = useTranslation(["views/explore"]);
|
const { t } = useTranslation(["views/explore"]);
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const getAbsolutePositions = useCallback(() => {
|
const getAbsolutePositions = useCallback(() => {
|
||||||
if (!imgRef.current || !positions) return [];
|
if (!imgRef.current || !positions) return [];
|
||||||
const imgRect = imgRef.current.getBoundingClientRect();
|
const imgRect = imgRef.current.getBoundingClientRect();
|
||||||
return positions.map((pos) => ({
|
return positions.map((pos) => {
|
||||||
x: pos.x * imgRect.width,
|
return {
|
||||||
y: pos.y * imgRect.height,
|
x: pos.x * imgRect.width,
|
||||||
timestamp: pos.timestamp,
|
y: pos.y * imgRect.height,
|
||||||
lifecycle_item: pos.lifecycle_item,
|
timestamp: pos.timestamp,
|
||||||
}));
|
lifecycle_item: pos.lifecycle_item?.data?.zones
|
||||||
}, [positions, imgRef]);
|
? {
|
||||||
|
...pos.lifecycle_item,
|
||||||
|
data: {
|
||||||
|
...pos.lifecycle_item?.data,
|
||||||
|
zones_friendly_names: pos.lifecycle_item?.data.zones.map(
|
||||||
|
(zone) => {
|
||||||
|
return resolveZoneName(config, zone);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: pos.lifecycle_item,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [imgRef, positions, config]);
|
||||||
|
|
||||||
const generateStraightPath = useCallback((points: Position[]) => {
|
const generateStraightPath = useCallback((points: Position[]) => {
|
||||||
if (!points || points.length < 2) return "";
|
if (!points || points.length < 2) return "";
|
||||||
|
|||||||
@ -80,7 +80,7 @@ import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
import { DialogPortal } from "@radix-ui/react-dialog";
|
import { DialogPortal } from "@radix-ui/react-dialog";
|
||||||
import { useDetailStream } from "@/context/detail-stream-context";
|
import { useDetailStream } from "@/context/detail-stream-context";
|
||||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||||
@ -306,7 +306,7 @@ function DialogContentComponent({
|
|||||||
if (page === "tracking_details") {
|
if (page === "tracking_details") {
|
||||||
return (
|
return (
|
||||||
<TrackingDetails
|
<TrackingDetails
|
||||||
className={cn("size-full", !isDesktop && "flex flex-col gap-4")}
|
className={cn(isDesktop ? "size-full" : "flex flex-col gap-4")}
|
||||||
event={search as unknown as Event}
|
event={search as unknown as Event}
|
||||||
tabs={
|
tabs={
|
||||||
isDesktop ? (
|
isDesktop ? (
|
||||||
@ -584,7 +584,7 @@ export default function SearchDetailDialog({
|
|||||||
"scrollbar-container overflow-y-auto",
|
"scrollbar-container overflow-y-auto",
|
||||||
isDesktop &&
|
isDesktop &&
|
||||||
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-[70%]",
|
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-[70%]",
|
||||||
isMobile && "px-4",
|
isMobile && "flex h-full flex-col px-4",
|
||||||
)}
|
)}
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
if (isPopoverOpen) {
|
if (isPopoverOpen) {
|
||||||
@ -596,7 +596,7 @@ export default function SearchDetailDialog({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Header>
|
<Header className={cn(!isDesktop && "top-0 z-[60] mb-0")}>
|
||||||
<Title>{t("trackedObjectDetails")}</Title>
|
<Title>{t("trackedObjectDetails")}</Title>
|
||||||
<Description className="sr-only">
|
<Description className="sr-only">
|
||||||
{t("trackedObjectDetails")}
|
{t("trackedObjectDetails")}
|
||||||
@ -1078,12 +1078,31 @@ function ObjectDetailsTab({
|
|||||||
});
|
});
|
||||||
|
|
||||||
setState("submitted");
|
setState("submitted");
|
||||||
setSearch({
|
mutate(
|
||||||
...search,
|
(key) =>
|
||||||
plus_id: "new_upload",
|
typeof key === "string" &&
|
||||||
});
|
(key.includes("events") ||
|
||||||
|
key.includes("events/search") ||
|
||||||
|
key.includes("events/explore")),
|
||||||
|
(currentData: SearchResult[][] | SearchResult[] | undefined) => {
|
||||||
|
if (!currentData) return currentData;
|
||||||
|
// optimistic update
|
||||||
|
return currentData
|
||||||
|
.flat()
|
||||||
|
.map((event) =>
|
||||||
|
event.id === search.id
|
||||||
|
? { ...event, plus_id: "new_upload" }
|
||||||
|
: event,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
optimisticData: true,
|
||||||
|
rollbackOnError: true,
|
||||||
|
revalidate: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[search, setSearch],
|
[search, mutate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -1242,106 +1261,110 @@ function ObjectDetailsTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{search.data.type === "object" &&
|
||||||
className={cn(
|
config?.plus?.enabled &&
|
||||||
"my-2 flex w-full flex-col justify-between gap-1.5",
|
search.has_snapshot && (
|
||||||
state == "submitted" && "flex-row",
|
<div
|
||||||
)}
|
className={cn(
|
||||||
>
|
"my-2 flex w-full flex-col justify-between gap-1.5",
|
||||||
<div className="text-sm text-primary/40">
|
state == "submitted" && "flex-row",
|
||||||
<div className="flex flex-row items-center gap-1">
|
)}
|
||||||
{t("explore.plus.submitToPlus.label", {
|
>
|
||||||
ns: "components/dialog",
|
<div className="text-sm text-primary/40">
|
||||||
})}
|
<div className="flex flex-row items-center gap-1">
|
||||||
<Popover>
|
{t("explore.plus.submitToPlus.label", {
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<div className="cursor-pointer p-0">
|
|
||||||
<LuInfo className="size-4" />
|
|
||||||
<span className="sr-only">Info</span>
|
|
||||||
</div>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
container={popoverContainerRef.current}
|
|
||||||
className="w-80 text-xs"
|
|
||||||
>
|
|
||||||
{t("explore.plus.submitToPlus.desc", {
|
|
||||||
ns: "components/dialog",
|
ns: "components/dialog",
|
||||||
})}
|
})}
|
||||||
</PopoverContent>
|
<Popover>
|
||||||
</Popover>
|
<PopoverTrigger asChild>
|
||||||
</div>
|
<div className="cursor-pointer p-0">
|
||||||
</div>
|
<LuInfo className="size-4" />
|
||||||
|
<span className="sr-only">Info</span>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
container={popoverContainerRef.current}
|
||||||
|
className="w-80 text-xs"
|
||||||
|
>
|
||||||
|
{t("explore.plus.submitToPlus.desc", {
|
||||||
|
ns: "components/dialog",
|
||||||
|
})}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center justify-between gap-2 text-sm">
|
<div className="flex flex-row items-center justify-between gap-2 text-sm">
|
||||||
{state == "reviewing" && (
|
{state == "reviewing" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
{i18n.language === "en" ? (
|
{i18n.language === "en" ? (
|
||||||
// English with a/an logic plus label
|
// English with a/an logic plus label
|
||||||
<>
|
<>
|
||||||
{/^[aeiou]/i.test(search?.label || "") ? (
|
{/^[aeiou]/i.test(search?.label || "") ? (
|
||||||
<Trans
|
<Trans
|
||||||
ns="components/dialog"
|
ns="components/dialog"
|
||||||
values={{ label: search?.label }}
|
values={{ label: search?.label }}
|
||||||
>
|
>
|
||||||
explore.plus.review.question.ask_an
|
explore.plus.review.question.ask_an
|
||||||
</Trans>
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans
|
||||||
|
ns="components/dialog"
|
||||||
|
values={{ label: search?.label }}
|
||||||
|
>
|
||||||
|
explore.plus.review.question.ask_a
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
// For other languages
|
||||||
<Trans
|
<Trans
|
||||||
ns="components/dialog"
|
ns="components/dialog"
|
||||||
values={{ label: search?.label }}
|
values={{
|
||||||
|
untranslatedLabel: search?.label,
|
||||||
|
translatedLabel: getTranslatedLabel(search?.label),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
explore.plus.review.question.ask_a
|
explore.plus.review.question.ask_full
|
||||||
</Trans>
|
</Trans>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
) : (
|
<div className="flex max-w-xl flex-row gap-2">
|
||||||
// For other languages
|
<Button
|
||||||
<Trans
|
className="flex-1 bg-success"
|
||||||
ns="components/dialog"
|
aria-label={t("button.yes", { ns: "common" })}
|
||||||
values={{
|
onClick={() => {
|
||||||
untranslatedLabel: search?.label,
|
setState("uploading");
|
||||||
translatedLabel: getTranslatedLabel(search?.label),
|
onSubmitToPlus(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
explore.plus.review.question.ask_full
|
{t("button.yes", { ns: "common" })}
|
||||||
</Trans>
|
</Button>
|
||||||
)}
|
<Button
|
||||||
</div>
|
className="flex-1 text-white"
|
||||||
<div className="flex max-w-xl flex-row gap-2">
|
aria-label={t("button.no", { ns: "common" })}
|
||||||
<Button
|
variant="destructive"
|
||||||
className="flex-1 bg-success"
|
onClick={() => {
|
||||||
aria-label={t("button.yes", { ns: "common" })}
|
setState("uploading");
|
||||||
onClick={() => {
|
onSubmitToPlus(true);
|
||||||
setState("uploading");
|
}}
|
||||||
onSubmitToPlus(false);
|
>
|
||||||
}}
|
{t("button.no", { ns: "common" })}
|
||||||
>
|
</Button>
|
||||||
{t("button.yes", { ns: "common" })}
|
</div>
|
||||||
</Button>
|
</>
|
||||||
<Button
|
)}
|
||||||
className="flex-1 text-white"
|
{state == "uploading" && <ActivityIndicator />}
|
||||||
aria-label={t("button.no", { ns: "common" })}
|
{state == "submitted" && (
|
||||||
variant="destructive"
|
<div className="flex flex-row items-center justify-center gap-2">
|
||||||
onClick={() => {
|
<FaCheckCircle className="size-4 text-success" />
|
||||||
setState("uploading");
|
{t("explore.plus.review.state.submitted")}
|
||||||
onSubmitToPlus(true);
|
</div>
|
||||||
}}
|
)}
|
||||||
>
|
|
||||||
{t("button.no", { ns: "common" })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{state == "uploading" && <ActivityIndicator />}
|
|
||||||
{state == "submitted" && (
|
|
||||||
<div className="flex flex-row items-center justify-center gap-2">
|
|
||||||
<FaCheckCircle className="size-4 text-success" />
|
|
||||||
{t("explore.plus.review.state.submitted")}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{config?.cameras[search.camera].objects.genai.enabled &&
|
{config?.cameras[search.camera].objects.genai.enabled &&
|
||||||
!search.end_time &&
|
!search.end_time &&
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { Link, useNavigate } from "react-router-dom";
|
|||||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { HiDotsHorizontal } from "react-icons/hi";
|
import { HiDotsHorizontal } from "react-icons/hi";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@ -73,6 +74,12 @@ export function TrackingDetails({
|
|||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
eventSequence?.map((event) => {
|
||||||
|
event.data.zones_friendly_names = event.data?.zones?.map((zone) => {
|
||||||
|
return resolveZoneName(config, zone);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Use manualOverride (set when seeking in image mode) if present so
|
// Use manualOverride (set when seeking in image mode) if present so
|
||||||
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall
|
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall
|
||||||
// back to currentTime used for video mode.
|
// back to currentTime used for video mode.
|
||||||
@ -345,7 +352,8 @@ export function TrackingDetails({
|
|||||||
className={cn(
|
className={cn(
|
||||||
isDesktop
|
isDesktop
|
||||||
? "flex size-full justify-evenly gap-4 overflow-hidden"
|
? "flex size-full justify-evenly gap-4 overflow-hidden"
|
||||||
: "flex size-full flex-col gap-2",
|
: "flex flex-col gap-2",
|
||||||
|
!isDesktop && cameraAspect === "tall" && "size-full",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -457,7 +465,7 @@ export function TrackingDetails({
|
|||||||
>
|
>
|
||||||
{config?.cameras[event.camera]?.onvif.autotracking
|
{config?.cameras[event.camera]?.onvif.autotracking
|
||||||
.enabled_in_config && (
|
.enabled_in_config && (
|
||||||
<div className="mb-2 text-sm text-danger">
|
<div className="mb-2 ml-3 text-sm text-danger">
|
||||||
{t("trackingDetails.autoTrackingTips")}
|
{t("trackingDetails.autoTrackingTips")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -712,8 +720,13 @@ function LifecycleIconRow({
|
|||||||
backgroundColor: `rgb(${color})`,
|
backgroundColor: `rgb(${color})`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="smart-capitalize">
|
<span
|
||||||
{zone.replaceAll("_", " ")}
|
className={cn(
|
||||||
|
item.data?.zones_friendly_names?.[zidx] === zone &&
|
||||||
|
"smart-capitalize",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.data?.zones_friendly_names?.[zidx]}
|
||||||
</span>
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,7 +20,9 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
type PlatformAwareDialogProps = {
|
type PlatformAwareDialogProps = {
|
||||||
trigger: JSX.Element;
|
trigger: JSX.Element;
|
||||||
@ -79,6 +81,8 @@ export function PlatformAwareSheet({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: PlatformAwareSheetProps) {
|
}: PlatformAwareSheetProps) {
|
||||||
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<MobilePage open={open} onOpenChange={onOpenChange}>
|
<MobilePage open={open} onOpenChange={onOpenChange}>
|
||||||
@ -86,14 +90,22 @@ export function PlatformAwareSheet({
|
|||||||
{trigger}
|
{trigger}
|
||||||
</MobilePageTrigger>
|
</MobilePageTrigger>
|
||||||
<MobilePagePortal>
|
<MobilePagePortal>
|
||||||
<MobilePageContent className="h-full overflow-hidden">
|
<MobilePageContent
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
scrollerRef={scrollerRef}
|
||||||
|
>
|
||||||
<MobilePageHeader
|
<MobilePageHeader
|
||||||
className="mx-2"
|
className="mx-2"
|
||||||
onClose={() => onOpenChange(false)}
|
onClose={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
<MobilePageTitle>{title}</MobilePageTitle>
|
<MobilePageTitle>{title}</MobilePageTitle>
|
||||||
</MobilePageHeader>
|
</MobilePageHeader>
|
||||||
<div className={contentClassName}>{content}</div>
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
|
className={cn("flex-1 overflow-y-auto", contentClassName)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
</MobilePageContent>
|
</MobilePageContent>
|
||||||
</MobilePagePortal>
|
</MobilePagePortal>
|
||||||
</MobilePage>
|
</MobilePage>
|
||||||
|
|||||||
@ -98,7 +98,11 @@ export default function RestartDialog({
|
|||||||
open={restartingSheetOpen}
|
open={restartingSheetOpen}
|
||||||
onOpenChange={() => setRestartingSheetOpen(false)}
|
onOpenChange={() => setRestartingSheetOpen(false)}
|
||||||
>
|
>
|
||||||
<SheetContent side="top" onInteractOutside={(e) => e.preventDefault()}>
|
<SheetContent
|
||||||
|
side="top"
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
className="[&>button:first-of-type]:hidden"
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
<SheetHeader className="mt-5 text-center">
|
<SheetHeader className="mt-5 text-center">
|
||||||
|
|||||||
@ -230,6 +230,7 @@ export default function SearchFilterDialog({
|
|||||||
<PlatformAwareSheet
|
<PlatformAwareSheet
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
title={t("more")}
|
title={t("more")}
|
||||||
|
titleClassName="mb-5 -mt-3"
|
||||||
content={content}
|
content={content}
|
||||||
contentClassName={cn(
|
contentClassName={cn(
|
||||||
"w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4",
|
"w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4",
|
||||||
@ -429,7 +430,8 @@ export function ZoneFilterContent({
|
|||||||
{allZones.map((item) => (
|
{allZones.map((item) => (
|
||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
key={item}
|
key={item}
|
||||||
label={item.replaceAll("_", " ")}
|
label={item}
|
||||||
|
type={"zone"}
|
||||||
isChecked={zones?.includes(item) ?? false}
|
isChecked={zones?.includes(item) ?? false}
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
|
|||||||
@ -262,13 +262,17 @@ export function PolygonCanvas({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activePolygonIndex === undefined || !polygons) {
|
if (activePolygonIndex === undefined || !polygons?.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPolygons = [...polygons];
|
const updatedPolygons = [...polygons];
|
||||||
const activePolygon = updatedPolygons[activePolygonIndex];
|
const activePolygon = updatedPolygons[activePolygonIndex];
|
||||||
|
|
||||||
|
if (!activePolygon) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// add default points order for already completed polygons
|
// add default points order for already completed polygons
|
||||||
if (!activePolygon.pointsOrder && activePolygon.isFinished) {
|
if (!activePolygon.pointsOrder && activePolygon.isFinished) {
|
||||||
updatedPolygons[activePolygonIndex] = {
|
updatedPolygons[activePolygonIndex] = {
|
||||||
|
|||||||
@ -179,7 +179,7 @@ export default function PolygonItem({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||||
name: polygon?.name,
|
name: polygon?.friendly_name ?? polygon?.name,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
@ -261,7 +261,9 @@ export default function PolygonItem({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="cursor-default">{polygon.name}</p>
|
<p className="cursor-default">
|
||||||
|
{polygon.friendly_name ?? polygon.name}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
@ -278,7 +280,7 @@ export default function PolygonItem({
|
|||||||
ns="views/settings"
|
ns="views/settings"
|
||||||
values={{
|
values={{
|
||||||
type: polygon.type.replace("_", " "),
|
type: polygon.type.replace("_", " "),
|
||||||
name: polygon.name,
|
name: polygon.friendly_name ?? polygon.name,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
masksAndZones.form.polygonDrawing.delete.desc
|
masksAndZones.form.polygonDrawing.delete.desc
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
import { getTranslatedLabel } from "@/utils/i18n";
|
import { getTranslatedLabel } from "@/utils/i18n";
|
||||||
|
import NameAndIdFields from "../input/NameAndIdFields";
|
||||||
|
|
||||||
type ZoneEditPaneProps = {
|
type ZoneEditPaneProps = {
|
||||||
polygons?: Polygon[];
|
polygons?: Polygon[];
|
||||||
@ -146,15 +147,37 @@ export default function ZoneEditPane({
|
|||||||
"masksAndZones.form.zoneName.error.mustNotContainPeriod",
|
"masksAndZones.form.zoneName.error.mustNotContainPeriod",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
|
friendly_name: z
|
||||||
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
|
.string()
|
||||||
})
|
.min(2, {
|
||||||
.refine((value: string) => /[a-zA-Z]/.test(value), {
|
|
||||||
message: t(
|
message: t(
|
||||||
"masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter",
|
"masksAndZones.form.zoneName.error.mustBeAtLeastTwoCharacters",
|
||||||
),
|
),
|
||||||
}),
|
})
|
||||||
|
.refine(
|
||||||
|
(value: string) => {
|
||||||
|
return !cameras.map((cam) => cam.name).includes(value);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t(
|
||||||
|
"masksAndZones.form.zoneName.error.mustNotBeSameWithCamera",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(value: string) => {
|
||||||
|
const otherPolygonNames =
|
||||||
|
polygons
|
||||||
|
?.filter((_, index) => index !== activePolygonIndex)
|
||||||
|
.map((polygon) => polygon.name) || [];
|
||||||
|
|
||||||
|
return !otherPolygonNames.includes(value);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("masksAndZones.form.zoneName.error.alreadyExists"),
|
||||||
|
},
|
||||||
|
),
|
||||||
inertia: z.coerce
|
inertia: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.min(1, {
|
.min(1, {
|
||||||
@ -247,6 +270,7 @@ export default function ZoneEditPane({
|
|||||||
mode: "onBlur",
|
mode: "onBlur",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: polygon?.name ?? "",
|
name: polygon?.name ?? "",
|
||||||
|
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
|
||||||
inertia:
|
inertia:
|
||||||
polygon?.camera &&
|
polygon?.camera &&
|
||||||
polygon?.name &&
|
polygon?.name &&
|
||||||
@ -286,6 +310,7 @@ export default function ZoneEditPane({
|
|||||||
async (
|
async (
|
||||||
{
|
{
|
||||||
name: zoneName,
|
name: zoneName,
|
||||||
|
friendly_name,
|
||||||
inertia,
|
inertia,
|
||||||
loitering_time,
|
loitering_time,
|
||||||
objects: form_objects,
|
objects: form_objects,
|
||||||
@ -415,9 +440,14 @@ export default function ZoneEditPane({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let friendlyNameQuery = "";
|
||||||
|
if (friendly_name) {
|
||||||
|
friendlyNameQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.friendly_name=${encodeURIComponent(friendly_name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.put(
|
.put(
|
||||||
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
|
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
|
||||||
{
|
{
|
||||||
requires_restart: 0,
|
requires_restart: 0,
|
||||||
update_topic: `config/cameras/${polygon.camera}/zones`,
|
update_topic: `config/cameras/${polygon.camera}/zones`,
|
||||||
@ -427,7 +457,7 @@ export default function ZoneEditPane({
|
|||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("masksAndZones.zones.toast.success", {
|
t("masksAndZones.zones.toast.success", {
|
||||||
zoneName,
|
zoneName: friendly_name || zoneName,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
@ -541,26 +571,17 @@ export default function ZoneEditPane({
|
|||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6">
|
||||||
<FormField
|
<NameAndIdFields
|
||||||
|
type="zone"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
nameField="friendly_name"
|
||||||
render={({ field }) => (
|
idField="name"
|
||||||
<FormItem>
|
idVisible={(polygon && polygon.name.length > 0) ?? false}
|
||||||
<FormLabel>{t("masksAndZones.zones.name.title")}</FormLabel>
|
nameLabel={t("masksAndZones.zones.name.title")}
|
||||||
<FormControl>
|
nameDescription={t("masksAndZones.zones.name.tips")}
|
||||||
<Input
|
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
|
||||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
|
|
||||||
placeholder={t("masksAndZones.zones.name.inputPlaceHolder")}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("masksAndZones.zones.name.tips")}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { Link } from "react-router-dom";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { usePersistence } from "@/hooks/use-persistence";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
|
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||||
import { MdAutoAwesome } from "react-icons/md";
|
import { MdAutoAwesome } from "react-icons/md";
|
||||||
|
|
||||||
@ -192,7 +193,7 @@ export default function DetailStream({
|
|||||||
<div className="relative flex h-full flex-col">
|
<div className="relative flex h-full flex-col">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="scrollbar-container flex-1 overflow-y-auto pb-14"
|
className="scrollbar-container flex-1 overflow-y-auto overflow-x-hidden pb-14"
|
||||||
>
|
>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
{reviewItems?.length === 0 ? (
|
{reviewItems?.length === 0 ? (
|
||||||
@ -793,17 +794,28 @@ function ObjectTimeline({
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const timeline = useMemo(() => {
|
const timeline = useMemo(() => {
|
||||||
if (!fullTimeline) {
|
if (!fullTimeline) {
|
||||||
return fullTimeline;
|
return fullTimeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fullTimeline.filter(
|
return fullTimeline
|
||||||
(t) =>
|
.filter(
|
||||||
t.timestamp >= review.start_time &&
|
(t) =>
|
||||||
(review.end_time == undefined || t.timestamp <= review.end_time),
|
t.timestamp >= review.start_time &&
|
||||||
);
|
(review.end_time == undefined || t.timestamp <= review.end_time),
|
||||||
}, [fullTimeline, review]);
|
)
|
||||||
|
.map((event) => ({
|
||||||
|
...event,
|
||||||
|
data: {
|
||||||
|
...event.data,
|
||||||
|
zones_friendly_names: event.data?.zones?.map((zone) =>
|
||||||
|
resolveZoneName(config, zone),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [config, fullTimeline, review]);
|
||||||
|
|
||||||
if (isValidating && (!timeline || timeline.length === 0)) {
|
if (isValidating && (!timeline || timeline.length === 0)) {
|
||||||
return <ActivityIndicator className="ml-2 size-3" />;
|
return <ActivityIndicator className="ml-2 size-3" />;
|
||||||
@ -811,7 +823,7 @@ function ObjectTimeline({
|
|||||||
|
|
||||||
if (!timeline || timeline.length === 0) {
|
if (!timeline || timeline.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="py-2 text-sm text-muted-foreground">
|
<div className="ml-8 text-sm text-muted-foreground">
|
||||||
{t("detail.noObjectDetailData")}
|
{t("detail.noObjectDetailData")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -55,20 +55,24 @@ export default function EventMenu({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem onSelect={handleObjectSelect}>
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onSelect={handleObjectSelect}
|
||||||
|
>
|
||||||
{isSelected
|
{isSelected
|
||||||
? t("itemMenu.hideObjectDetails.label")
|
? t("itemMenu.hideObjectDetails.label")
|
||||||
: t("itemMenu.showObjectDetails.label")}
|
: t("itemMenu.showObjectDetails.label")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className="my-0.5" />
|
<DropdownMenuSeparator className="my-0.5" />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(`/explore?event_id=${event.id}`);
|
navigate(`/explore?event_id=${event.id}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("details.item.button.viewInExplore")}
|
{t("details.item.button.viewInExplore")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem className="cursor-pointer" asChild>
|
||||||
<a
|
<a
|
||||||
download
|
download
|
||||||
href={
|
href={
|
||||||
@ -86,6 +90,7 @@ export default function EventMenu({
|
|||||||
event.data.type == "object" &&
|
event.data.type == "object" &&
|
||||||
config?.plus?.enabled && (
|
config?.plus?.enabled && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
onOpenUpload?.(event);
|
onOpenUpload?.(event);
|
||||||
@ -97,6 +102,7 @@ export default function EventMenu({
|
|||||||
|
|
||||||
{event.has_snapshot && config?.semantic_search?.enabled && (
|
{event.has_snapshot && config?.semantic_search?.enabled && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (onOpenSimilarity) onOpenSimilarity(event);
|
if (onOpenSimilarity) onOpenSimilarity(event);
|
||||||
else
|
else
|
||||||
|
|||||||
41
web/src/hooks/use-zone-friendly-name.ts
Normal file
41
web/src/hooks/use-zone-friendly-name.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
export function resolveZoneName(
|
||||||
|
config: FrigateConfig | undefined,
|
||||||
|
zoneId: string,
|
||||||
|
cameraId?: string,
|
||||||
|
) {
|
||||||
|
if (!config) return String(zoneId).replace(/_/g, " ");
|
||||||
|
|
||||||
|
if (cameraId) {
|
||||||
|
const camera = config.cameras?.[String(cameraId)];
|
||||||
|
const zone = camera?.zones?.[zoneId];
|
||||||
|
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const camKey in config.cameras) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(config.cameras, camKey)) continue;
|
||||||
|
const cam = config.cameras[camKey];
|
||||||
|
if (!cam?.zones) continue;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(cam.zones, zoneId)) {
|
||||||
|
const zone = cam.zones[zoneId];
|
||||||
|
return zone?.friendly_name || String(zoneId).replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return a cleaned-up zoneId string
|
||||||
|
return String(zoneId).replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZoneFriendlyName(zoneId: string, cameraId?: string): string {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
const name = useMemo(
|
||||||
|
() => resolveZoneName(config, zoneId, cameraId),
|
||||||
|
[config, cameraId, zoneId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
@ -42,7 +42,7 @@ import { useInitialCameraState } from "@/api/ws";
|
|||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import TriggerView from "@/views/settings/TriggerView";
|
import TriggerView from "@/views/settings/TriggerView";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@ -650,7 +650,7 @@ function CameraSelectButton({
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
isChecked={item.name === selectedCamera}
|
isChecked={item.name === selectedCamera}
|
||||||
label={item.name}
|
label={item.name}
|
||||||
isCameraName={true}
|
type={"camera"}
|
||||||
onCheckedChange={(isChecked) => {
|
onCheckedChange={(isChecked) => {
|
||||||
if (isChecked && (isEnabled || isCameraSettingsPage)) {
|
if (isChecked && (isEnabled || isCameraSettingsPage)) {
|
||||||
setSelectedCamera(item.name);
|
setSelectedCamera(item.name);
|
||||||
|
|||||||
@ -11,10 +11,12 @@ export type Polygon = {
|
|||||||
distances: number[];
|
distances: number[];
|
||||||
isFinished: boolean;
|
isFinished: boolean;
|
||||||
color: number[];
|
color: number[];
|
||||||
|
friendly_name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ZoneFormValuesType = {
|
export type ZoneFormValuesType = {
|
||||||
name: string;
|
name: string;
|
||||||
|
friendly_name: string;
|
||||||
inertia: number;
|
inertia: number;
|
||||||
loitering_time: number;
|
loitering_time: number;
|
||||||
isFinished: boolean;
|
isFinished: boolean;
|
||||||
|
|||||||
@ -280,6 +280,7 @@ export interface CameraConfig {
|
|||||||
speed_threshold: number;
|
speed_threshold: number;
|
||||||
objects: string[];
|
objects: string[];
|
||||||
color: number[];
|
color: number[];
|
||||||
|
friendly_name?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export type TrackingDetailsSequence = {
|
|||||||
attribute: string;
|
attribute: string;
|
||||||
attribute_box?: [number, number, number, number];
|
attribute_box?: [number, number, number, number];
|
||||||
zones: string[];
|
zones: string[];
|
||||||
|
zones_friendly_names?: string[];
|
||||||
};
|
};
|
||||||
class_type: LifecycleClassType;
|
class_type: LifecycleClassType;
|
||||||
source_id: string;
|
source_id: string;
|
||||||
|
|||||||
@ -1,25 +1,7 @@
|
|||||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
import { getTranslatedLabel } from "./i18n";
|
import { getTranslatedLabel } from "./i18n";
|
||||||
import { capitalizeFirstLetter } from "./stringUtil";
|
import { capitalizeFirstLetter, formatList } from "./stringUtil";
|
||||||
|
|
||||||
function formatZonesList(zones: string[]): string {
|
|
||||||
if (zones.length === 0) return "";
|
|
||||||
if (zones.length === 1) return zones[0];
|
|
||||||
if (zones.length === 2) {
|
|
||||||
return t("list.two", {
|
|
||||||
0: zones[0],
|
|
||||||
1: zones[1],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" });
|
|
||||||
const allButLast = zones.slice(0, -1).join(separatorWithSpace);
|
|
||||||
return t("list.many", {
|
|
||||||
items: allButLast,
|
|
||||||
last: zones[zones.length - 1],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLifecycleItemDescription(
|
export function getLifecycleItemDescription(
|
||||||
lifecycleItem: TrackingDetailsSequence,
|
lifecycleItem: TrackingDetailsSequence,
|
||||||
@ -42,7 +24,9 @@ export function getLifecycleItemDescription(
|
|||||||
return t("trackingDetails.lifecycleItemDesc.entered_zone", {
|
return t("trackingDetails.lifecycleItemDesc.entered_zone", {
|
||||||
ns: "views/explore",
|
ns: "views/explore",
|
||||||
label,
|
label,
|
||||||
zones: formatZonesList(lifecycleItem.data.zones),
|
zones: formatList(
|
||||||
|
lifecycleItem.data.zones_friendly_names ?? lifecycleItem.data.zones,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
case "active":
|
case "active":
|
||||||
return t("trackingDetails.lifecycleItemDesc.active", {
|
return t("trackingDetails.lifecycleItemDesc.active", {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { t } from "i18next";
|
||||||
|
|
||||||
export const capitalizeFirstLetter = (text: string): string => {
|
export const capitalizeFirstLetter = (text: string): string => {
|
||||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
};
|
};
|
||||||
@ -19,20 +21,30 @@ export const capitalizeAll = (text: string): string => {
|
|||||||
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
|
* @returns A valid camera identifier (lowercase, alphanumeric, max 8 chars)
|
||||||
*/
|
*/
|
||||||
export function generateFixedHash(name: string, prefix: string = "id"): string {
|
export function generateFixedHash(name: string, prefix: string = "id"): string {
|
||||||
// Safely encode Unicode as UTF-8 bytes
|
// Use the full UTF-8 bytes of the name and compute an FNV-1a 32-bit hash.
|
||||||
|
// This is deterministic, fast, works with Unicode and avoids collisions from
|
||||||
|
// simple truncation of base64 output.
|
||||||
const utf8Bytes = new TextEncoder().encode(name);
|
const utf8Bytes = new TextEncoder().encode(name);
|
||||||
|
|
||||||
// Convert to base64 manually
|
// FNV-1a 32-bit hash algorithm
|
||||||
let binary = "";
|
let hash = 0x811c9dc5; // FNV offset basis
|
||||||
for (const byte of utf8Bytes) {
|
for (let i = 0; i < utf8Bytes.length; i++) {
|
||||||
binary += String.fromCharCode(byte);
|
hash ^= utf8Bytes[i];
|
||||||
|
// Multiply by FNV prime (0x01000193) with 32-bit overflow
|
||||||
|
hash = (hash >>> 0) * 0x01000193;
|
||||||
|
// Ensure 32-bit unsigned integer
|
||||||
|
hash >>>= 0;
|
||||||
}
|
}
|
||||||
const base64 = btoa(binary);
|
|
||||||
|
|
||||||
// Strip out non-alphanumeric characters and truncate
|
// Convert to an 8-character lowercase hex string
|
||||||
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
|
const hashHex = (hash >>> 0).toString(16).padStart(8, "0").toLowerCase();
|
||||||
|
|
||||||
return `${prefix}_${cleanHash.toLowerCase()}`;
|
// Ensure the first character is a letter to avoid an identifier that's purely
|
||||||
|
// numeric (isValidId forbids all-digit IDs). If it starts with a digit,
|
||||||
|
// replace with 'a'. This is extremely unlikely but a simple safeguard.
|
||||||
|
const safeHash = /^[0-9]/.test(hashHex[0]) ? `a${hashHex.slice(1)}` : hashHex;
|
||||||
|
|
||||||
|
return `${prefix}_${safeHash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -45,3 +57,29 @@ export function generateFixedHash(name: string, prefix: string = "id"): string {
|
|||||||
export function isValidId(name: string): boolean {
|
export function isValidId(name: string): boolean {
|
||||||
return /^[a-zA-Z0-9_-]+$/.test(name) && !/^\d+$/.test(name);
|
return /^[a-zA-Z0-9_-]+$/.test(name) && !/^\d+$/.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a list of strings into a human-readable format with proper localization.
|
||||||
|
* Handles different cases for empty, single-item, two-item, and multi-item lists.
|
||||||
|
*
|
||||||
|
* @param item - The array of strings to format
|
||||||
|
* @returns A formatted string representation of the list
|
||||||
|
*/
|
||||||
|
export function formatList(item: string[]): string {
|
||||||
|
if (item.length === 0) return "";
|
||||||
|
if (item.length === 1) return item[0];
|
||||||
|
if (item.length === 2) {
|
||||||
|
return t("list.two", {
|
||||||
|
0: item[0],
|
||||||
|
1: item[1],
|
||||||
|
ns: "common",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" });
|
||||||
|
const allButLast = item.slice(0, -1).join(separatorWithSpace);
|
||||||
|
return t("list.many", {
|
||||||
|
items: allButLast,
|
||||||
|
last: item[item.length - 1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -118,6 +118,11 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
|
|
||||||
const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>();
|
const [trainFilter, setTrainFilter] = useApiFilter<TrainFilter>();
|
||||||
|
|
||||||
|
const refreshAll = useCallback(() => {
|
||||||
|
refreshTrain();
|
||||||
|
refreshDataset();
|
||||||
|
}, [refreshTrain, refreshDataset]);
|
||||||
|
|
||||||
// image multiselect
|
// image multiselect
|
||||||
|
|
||||||
const [selectedImages, setSelectedImages] = useState<string[]>([]);
|
const [selectedImages, setSelectedImages] = useState<string[]>([]);
|
||||||
@ -183,11 +188,12 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onDelete = useCallback(
|
const onDelete = useCallback(
|
||||||
(ids: string[], isName: boolean = false) => {
|
(ids: string[], isName: boolean = false, category?: string) => {
|
||||||
|
const targetCategory = category || pageToggle;
|
||||||
const api =
|
const api =
|
||||||
pageToggle == "train"
|
targetCategory == "train"
|
||||||
? `/classification/${model.name}/train/delete`
|
? `/classification/${model.name}/train/delete`
|
||||||
: `/classification/${model.name}/dataset/${pageToggle}/delete`;
|
: `/classification/${model.name}/dataset/${targetCategory}/delete`;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.post(api, { ids })
|
.post(api, { ids })
|
||||||
@ -408,7 +414,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
|||||||
trainImages={trainImages || []}
|
trainImages={trainImages || []}
|
||||||
trainFilter={trainFilter}
|
trainFilter={trainFilter}
|
||||||
selectedImages={selectedImages}
|
selectedImages={selectedImages}
|
||||||
onRefresh={refreshTrain}
|
onRefresh={refreshAll}
|
||||||
onClickImages={onClickImages}
|
onClickImages={onClickImages}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
/>
|
/>
|
||||||
@ -432,7 +438,7 @@ type LibrarySelectorProps = {
|
|||||||
dataset: { [id: string]: string[] };
|
dataset: { [id: string]: string[] };
|
||||||
trainImages: string[];
|
trainImages: string[];
|
||||||
setPageToggle: (toggle: string) => void;
|
setPageToggle: (toggle: string) => void;
|
||||||
onDelete: (ids: string[], isName: boolean) => void;
|
onDelete: (ids: string[], isName: boolean, category?: string) => void;
|
||||||
onRename: (old_name: string, new_name: string) => void;
|
onRename: (old_name: string, new_name: string) => void;
|
||||||
};
|
};
|
||||||
function LibrarySelector({
|
function LibrarySelector({
|
||||||
@ -448,7 +454,7 @@ function LibrarySelector({
|
|||||||
// data
|
// data
|
||||||
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
const [renameClass, setRenameFace] = useState<string | null>(null);
|
const [renameClass, setRenameClass] = useState<string | null>(null);
|
||||||
const pageTitle = useMemo(() => {
|
const pageTitle = useMemo(() => {
|
||||||
if (pageToggle != "train") {
|
if (pageToggle != "train") {
|
||||||
return pageToggle;
|
return pageToggle;
|
||||||
@ -463,12 +469,12 @@ function LibrarySelector({
|
|||||||
|
|
||||||
// interaction
|
// interaction
|
||||||
|
|
||||||
const handleDeleteFace = useCallback(
|
const handleDeleteCategory = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
// Get all image IDs for this face
|
// Get all image IDs for this category
|
||||||
const imageIds = dataset?.[name] || [];
|
const imageIds = dataset?.[name] || [];
|
||||||
|
|
||||||
onDelete(imageIds, true);
|
onDelete(imageIds, true, name);
|
||||||
setPageToggle("train");
|
setPageToggle("train");
|
||||||
},
|
},
|
||||||
[dataset, onDelete, setPageToggle],
|
[dataset, onDelete, setPageToggle],
|
||||||
@ -476,7 +482,7 @@ function LibrarySelector({
|
|||||||
|
|
||||||
const handleSetOpen = useCallback(
|
const handleSetOpen = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setRenameFace(open ? renameClass : null);
|
setRenameClass(open ? renameClass : null);
|
||||||
},
|
},
|
||||||
[renameClass],
|
[renameClass],
|
||||||
);
|
);
|
||||||
@ -503,7 +509,7 @@ function LibrarySelector({
|
|||||||
className="text-white"
|
className="text-white"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirmDelete) {
|
if (confirmDelete) {
|
||||||
handleDeleteFace(confirmDelete);
|
handleDeleteCategory(confirmDelete);
|
||||||
setConfirmDelete(null);
|
setConfirmDelete(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -521,7 +527,7 @@ function LibrarySelector({
|
|||||||
description={t("renameCategory.desc", { name: renameClass })}
|
description={t("renameCategory.desc", { name: renameClass })}
|
||||||
onSave={(newName) => {
|
onSave={(newName) => {
|
||||||
onRename(renameClass!, newName);
|
onRename(renameClass!, newName);
|
||||||
setRenameFace(null);
|
setRenameClass(null);
|
||||||
}}
|
}}
|
||||||
defaultValue={renameClass || ""}
|
defaultValue={renameClass || ""}
|
||||||
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
|
regexPattern={/^[\p{L}\p{N}\s'_-]{1,50}$/u}
|
||||||
@ -588,7 +594,7 @@ function LibrarySelector({
|
|||||||
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
className="size-7 lg:opacity-0 lg:transition-opacity lg:group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setRenameFace(id);
|
setRenameClass(id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuPencil className="size-4 text-primary" />
|
<LuPencil className="size-4 text-primary" />
|
||||||
|
|||||||
@ -236,10 +236,6 @@ function ExploreThumbnailImage({
|
|||||||
onSelectSearch(event, false, "tracking_details");
|
onSelectSearch(event, false, "tracking_details");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShowSnapshot = () => {
|
|
||||||
onSelectSearch(event, false, "snapshot");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddTrigger = () => {
|
const handleAddTrigger = () => {
|
||||||
navigate(
|
navigate(
|
||||||
`/settings?page=triggers&camera=${event.camera}&event_id=${event.id}`,
|
`/settings?page=triggers&camera=${event.camera}&event_id=${event.id}`,
|
||||||
@ -252,7 +248,6 @@ function ExploreThumbnailImage({
|
|||||||
findSimilar={handleFindSimilar}
|
findSimilar={handleFindSimilar}
|
||||||
refreshResults={mutate}
|
refreshResults={mutate}
|
||||||
showTrackingDetails={handleShowTrackingDetails}
|
showTrackingDetails={handleShowTrackingDetails}
|
||||||
showSnapshot={handleShowSnapshot}
|
|
||||||
addTrigger={handleAddTrigger}
|
addTrigger={handleAddTrigger}
|
||||||
isContextMenu={true}
|
isContextMenu={true}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -63,7 +63,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
import { DetailStreamProvider } from "@/context/detail-stream-context";
|
import { DetailStreamProvider } from "@/context/detail-stream-context";
|
||||||
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
|
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
|
||||||
@ -985,7 +985,7 @@ function Timeline({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isMobile && (
|
{isMobile && timelineType == "timeline" && (
|
||||||
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
|
<GenAISummaryDialog review={activeReviewItem} onOpen={onAnalysisOpen} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -688,9 +688,6 @@ export default function SearchView({
|
|||||||
showTrackingDetails={() =>
|
showTrackingDetails={() =>
|
||||||
onSelectSearch(value, false, "tracking_details")
|
onSelectSearch(value, false, "tracking_details")
|
||||||
}
|
}
|
||||||
showSnapshot={() =>
|
|
||||||
onSelectSearch(value, false, "snapshot")
|
|
||||||
}
|
|
||||||
addTrigger={() => {
|
addTrigger={() => {
|
||||||
if (
|
if (
|
||||||
config?.semantic_search.enabled &&
|
config?.semantic_search.enabled &&
|
||||||
|
|||||||
@ -36,7 +36,7 @@ import EditRoleCamerasDialog from "@/components/overlay/EditRoleCamerasDialog";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog";
|
import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type AuthenticationViewProps = {
|
type AuthenticationViewProps = {
|
||||||
section?: "users" | "roles";
|
section?: "users" | "roles";
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|||||||
@ -23,7 +23,6 @@ import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
|
||||||
import { MdCircle } from "react-icons/md";
|
import { MdCircle } from "react-icons/md";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
@ -42,6 +41,8 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
|||||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { isDesktop } from "react-device-detect";
|
import { isDesktop } from "react-device-detect";
|
||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
|
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||||
|
import { formatList } from "@/utils/stringUtil";
|
||||||
|
|
||||||
type CameraSettingsViewProps = {
|
type CameraSettingsViewProps = {
|
||||||
selectedCamera: string;
|
selectedCamera: string;
|
||||||
@ -86,11 +87,18 @@ export default function CameraSettingsView({
|
|||||||
|
|
||||||
// zones and labels
|
// zones and labels
|
||||||
|
|
||||||
|
const getZoneName = useCallback(
|
||||||
|
(zoneId: string, cameraId?: string) =>
|
||||||
|
resolveZoneName(config, zoneId, cameraId),
|
||||||
|
[config],
|
||||||
|
);
|
||||||
|
|
||||||
const zones = useMemo(() => {
|
const zones = useMemo(() => {
|
||||||
if (cameraConfig) {
|
if (cameraConfig) {
|
||||||
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
|
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
|
||||||
camera: cameraConfig.name,
|
camera: cameraConfig.name,
|
||||||
name,
|
name,
|
||||||
|
friendly_name: cameraConfig.zones[name].friendly_name,
|
||||||
objects: zoneData.objects,
|
objects: zoneData.objects,
|
||||||
color: zoneData.color,
|
color: zoneData.color,
|
||||||
}));
|
}));
|
||||||
@ -99,27 +107,27 @@ export default function CameraSettingsView({
|
|||||||
|
|
||||||
const alertsLabels = useMemo(() => {
|
const alertsLabels = useMemo(() => {
|
||||||
return cameraConfig?.review.alerts.labels
|
return cameraConfig?.review.alerts.labels
|
||||||
? cameraConfig.review.alerts.labels
|
? formatList(
|
||||||
.map((label) =>
|
cameraConfig.review.alerts.labels.map((label) =>
|
||||||
getTranslatedLabel(
|
getTranslatedLabel(
|
||||||
label,
|
label,
|
||||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
.join(", ")
|
)
|
||||||
: "";
|
: "";
|
||||||
}, [cameraConfig]);
|
}, [cameraConfig]);
|
||||||
|
|
||||||
const detectionsLabels = useMemo(() => {
|
const detectionsLabels = useMemo(() => {
|
||||||
return cameraConfig?.review.detections.labels
|
return cameraConfig?.review.detections.labels
|
||||||
? cameraConfig.review.detections.labels
|
? formatList(
|
||||||
.map((label) =>
|
cameraConfig.review.detections.labels.map((label) =>
|
||||||
getTranslatedLabel(
|
getTranslatedLabel(
|
||||||
label,
|
label,
|
||||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
.join(", ")
|
)
|
||||||
: "";
|
: "";
|
||||||
}, [cameraConfig]);
|
}, [cameraConfig]);
|
||||||
|
|
||||||
@ -525,8 +533,14 @@ export default function CameraSettingsView({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="font-normal smart-capitalize">
|
<FormLabel
|
||||||
{zone.name.replaceAll("_", " ")}
|
className={cn(
|
||||||
|
"font-normal",
|
||||||
|
!zone.friendly_name &&
|
||||||
|
"smart-capitalize",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{zone.friendly_name || zone.name}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -548,14 +562,11 @@ export default function CameraSettingsView({
|
|||||||
"cameraReview.reviewClassification.zoneObjectAlertsTips",
|
"cameraReview.reviewClassification.zoneObjectAlertsTips",
|
||||||
{
|
{
|
||||||
alertsLabels,
|
alertsLabels,
|
||||||
zone: watchedAlertsZones
|
zone: formatList(
|
||||||
.map((zone) =>
|
watchedAlertsZones.map((zone) =>
|
||||||
capitalizeFirstLetter(zone).replaceAll(
|
getZoneName(zone),
|
||||||
"_",
|
),
|
||||||
" ",
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
.join(", "),
|
|
||||||
cameraName: selectCameraName,
|
cameraName: selectCameraName,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -627,8 +638,14 @@ export default function CameraSettingsView({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className="font-normal smart-capitalize">
|
<FormLabel
|
||||||
{zone.name.replaceAll("_", " ")}
|
className={cn(
|
||||||
|
"font-normal",
|
||||||
|
!zone.friendly_name &&
|
||||||
|
"smart-capitalize",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{zone.friendly_name || zone.name}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -667,14 +684,11 @@ export default function CameraSettingsView({
|
|||||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
||||||
values={{
|
values={{
|
||||||
detectionsLabels,
|
detectionsLabels,
|
||||||
zone: watchedDetectionsZones
|
zone: formatList(
|
||||||
.map((zone) =>
|
watchedDetectionsZones.map((zone) =>
|
||||||
capitalizeFirstLetter(zone).replaceAll(
|
getZoneName(zone),
|
||||||
"_",
|
),
|
||||||
" ",
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
.join(", "),
|
|
||||||
cameraName: selectCameraName,
|
cameraName: selectCameraName,
|
||||||
}}
|
}}
|
||||||
ns="views/settings"
|
ns="views/settings"
|
||||||
@ -684,14 +698,11 @@ export default function CameraSettingsView({
|
|||||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||||
values={{
|
values={{
|
||||||
detectionsLabels,
|
detectionsLabels,
|
||||||
zone: watchedDetectionsZones
|
zone: formatList(
|
||||||
.map((zone) =>
|
watchedDetectionsZones.map((zone) =>
|
||||||
capitalizeFirstLetter(zone).replaceAll(
|
getZoneName(zone),
|
||||||
"_",
|
),
|
||||||
" ",
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
.join(", "),
|
|
||||||
cameraName: selectCameraName,
|
cameraName: selectCameraName,
|
||||||
}}
|
}}
|
||||||
ns="views/settings"
|
ns="views/settings"
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
|
|
||||||
type FrigatePlusModel = {
|
type FrigatePlusModel = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@ -229,6 +229,7 @@ export default function MasksAndZonesView({
|
|||||||
typeIndex: index,
|
typeIndex: index,
|
||||||
camera: cameraConfig.name,
|
camera: cameraConfig.name,
|
||||||
name,
|
name,
|
||||||
|
friendly_name: zoneData.friendly_name,
|
||||||
objects: zoneData.objects,
|
objects: zoneData.objects,
|
||||||
points: interpolatePoints(
|
points: interpolatePoints(
|
||||||
parseCoordinates(zoneData.coordinates),
|
parseCoordinates(zoneData.coordinates),
|
||||||
|
|||||||
@ -45,7 +45,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@ -476,7 +476,7 @@ export default function NotificationView({
|
|||||||
<FilterSwitch
|
<FilterSwitch
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
label={camera.name}
|
label={camera.name}
|
||||||
isCameraName={true}
|
type={"camera"}
|
||||||
isChecked={field.value?.includes(
|
isChecked={field.value?.includes(
|
||||||
camera.name,
|
camera.name,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -403,7 +403,8 @@ export default function TriggerView({
|
|||||||
setShowCreate(true);
|
setShowCreate(true);
|
||||||
setSelectedTrigger({
|
setSelectedTrigger({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
name: "",
|
name: eventId,
|
||||||
|
friendly_name: "",
|
||||||
type: "thumbnail",
|
type: "thumbnail",
|
||||||
data: eventId,
|
data: eventId,
|
||||||
threshold: 0.5,
|
threshold: 0.5,
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||||
|
|
||||||
type CameraMetricsProps = {
|
type CameraMetricsProps = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user