mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-22 08:08:22 +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
|
||||
|
||||
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
|
||||
|
||||
@ -18,8 +18,10 @@ Object detection and enrichments (like Semantic Search, Face Recognition, and Li
|
||||
- **Intel**
|
||||
|
||||
- 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 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.
|
||||
|
||||
|
||||
@ -261,6 +261,8 @@ OpenVINO is supported on 6th Gen Intel platforms (Skylake) and newer. It will al
|
||||
|
||||
:::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:
|
||||
|
||||
```yaml
|
||||
@ -283,7 +285,7 @@ detectors:
|
||||
| [RF-DETR](#rf-detr) | ✅ | ✅ | Requires XE iGPU or Arc |
|
||||
| [YOLO-NAS](#yolo-nas) | ✅ | ✅ | |
|
||||
| [MobileNet v2](#ssdlite-mobilenet-v2) | ✅ | ✅ | Fast and lightweight model, less accurate than larger models |
|
||||
| [YOLOX](#yolox) | ✅ | ? | |
|
||||
| [YOLOX](#yolox) | ✅ | ? | |
|
||||
| [D-FINE](#d-fine) | ❌ | ❌ | |
|
||||
|
||||
#### 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
|
||||
# camera.
|
||||
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.
|
||||
# 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
|
||||
|
||||
@ -78,7 +78,7 @@ Switching between V1 and V2 requires reindexing your embeddings. The embeddings
|
||||
|
||||
### 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
|
||||
semantic_search:
|
||||
@ -90,7 +90,7 @@ semantic_search:
|
||||
|
||||
:::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/)).
|
||||
If you do not specify a device, the first available GPU will be used.
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ cameras:
|
||||
- entire_yard
|
||||
zones:
|
||||
entire_yard:
|
||||
friendly_name: Entire yard # You can use characters from any language text
|
||||
coordinates: ...
|
||||
```
|
||||
|
||||
@ -44,8 +45,10 @@ cameras:
|
||||
- edge_yard
|
||||
zones:
|
||||
edge_yard:
|
||||
friendly_name: Edge yard # You can use characters from any language text
|
||||
coordinates: ...
|
||||
inner_yard:
|
||||
friendly_name: Inner yard # You can use characters from any language text
|
||||
coordinates: ...
|
||||
```
|
||||
|
||||
@ -59,6 +62,7 @@ cameras:
|
||||
- entire_yard
|
||||
zones:
|
||||
entire_yard:
|
||||
friendly_name: Entire yard
|
||||
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.
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
@ -13,6 +13,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ZoneConfig(BaseModel):
|
||||
friendly_name: Optional[str] = Field(
|
||||
None, title="Zone friendly name used in the Frigate UI."
|
||||
)
|
||||
filters: dict[str, FilterConfig] = Field(
|
||||
default_factory=dict, title="Zone filters."
|
||||
)
|
||||
|
||||
@ -418,8 +418,8 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
obj_data["box"][2],
|
||||
obj_data["box"][3],
|
||||
max(
|
||||
obj_data["box"][1] - obj_data["box"][0],
|
||||
obj_data["box"][3] - obj_data["box"][2],
|
||||
obj_data["box"][2] - obj_data["box"][0],
|
||||
obj_data["box"][3] - obj_data["box"][1],
|
||||
),
|
||||
1.0,
|
||||
)
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
@ -161,12 +162,12 @@ class CudaGraphRunner(BaseModelRunner):
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_complex_model(model_type: str) -> bool:
|
||||
def is_model_supported(model_type: str) -> bool:
|
||||
# Import here to avoid circular imports
|
||||
from frigate.detectors.detector_config import ModelTypeEnum
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
|
||||
return model_type in [
|
||||
return model_type not in [
|
||||
ModelTypeEnum.yolonas.value,
|
||||
EnrichmentModelTypeEnum.paddleocr.value,
|
||||
EnrichmentModelTypeEnum.jina_v1.value,
|
||||
@ -239,9 +240,30 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
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):
|
||||
self.model_path = model_path
|
||||
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)
|
||||
|
||||
if not os.path.isfile(model_path):
|
||||
@ -269,6 +291,10 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
self.infer_request = self.compiled_model.create_infer_request()
|
||||
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:
|
||||
try:
|
||||
input_shape = self.compiled_model.inputs[0].get_shape()
|
||||
@ -312,67 +338,70 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
Returns:
|
||||
List of output tensors
|
||||
"""
|
||||
# Handle single input case for backward compatibility
|
||||
if (
|
||||
len(inputs) == 1
|
||||
and len(self.compiled_model.inputs) == 1
|
||||
and self.input_tensor is not None
|
||||
):
|
||||
# Single input case - use the pre-allocated tensor for efficiency
|
||||
input_data = list(inputs.values())[0]
|
||||
np.copyto(self.input_tensor.data, input_data)
|
||||
self.infer_request.infer(self.input_tensor)
|
||||
else:
|
||||
if self.complex_model:
|
||||
try:
|
||||
# This ensures the model starts with a clean state for each sequence
|
||||
# Important for RNN models like PaddleOCR recognition
|
||||
self.infer_request.reset_state()
|
||||
except Exception:
|
||||
# this will raise an exception for models with AUTO set as the device
|
||||
pass
|
||||
# Lock prevents concurrent access to infer_request
|
||||
# Needed for JinaV2: genai thread (text) + embeddings thread (vision)
|
||||
with self._inference_lock:
|
||||
# Handle single input case for backward compatibility
|
||||
if (
|
||||
len(inputs) == 1
|
||||
and len(self.compiled_model.inputs) == 1
|
||||
and self.input_tensor is not None
|
||||
):
|
||||
# Single input case - use the pre-allocated tensor for efficiency
|
||||
input_data = list(inputs.values())[0]
|
||||
np.copyto(self.input_tensor.data, input_data)
|
||||
self.infer_request.infer(self.input_tensor)
|
||||
else:
|
||||
if self.complex_model:
|
||||
try:
|
||||
# This ensures the model starts with a clean state for each sequence
|
||||
# Important for RNN models like PaddleOCR recognition
|
||||
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
|
||||
for input_name, input_data in inputs.items():
|
||||
# Find the input by name and its index
|
||||
input_port = None
|
||||
input_index = None
|
||||
for idx, port in enumerate(self.compiled_model.inputs):
|
||||
if port.get_any_name() == input_name:
|
||||
input_port = port
|
||||
input_index = idx
|
||||
break
|
||||
# Multiple inputs case - set each input by name
|
||||
for input_name, input_data in inputs.items():
|
||||
# Find the input by name and its index
|
||||
input_port = None
|
||||
input_index = None
|
||||
for idx, port in enumerate(self.compiled_model.inputs):
|
||||
if port.get_any_name() == input_name:
|
||||
input_port = port
|
||||
input_index = idx
|
||||
break
|
||||
|
||||
if input_port is None:
|
||||
raise ValueError(f"Input '{input_name}' not found in model")
|
||||
if input_port is None:
|
||||
raise ValueError(f"Input '{input_name}' not found in model")
|
||||
|
||||
# Create tensor with the correct element type
|
||||
input_element_type = input_port.get_element_type()
|
||||
# Create tensor with the correct element type
|
||||
input_element_type = input_port.get_element_type()
|
||||
|
||||
# Ensure input data matches the expected dtype to prevent type mismatches
|
||||
# that can occur with models like Jina-CLIP v2 running on OpenVINO
|
||||
expected_dtype = input_element_type.to_dtype()
|
||||
if input_data.dtype != expected_dtype:
|
||||
logger.debug(
|
||||
f"Converting input '{input_name}' from {input_data.dtype} to {expected_dtype}"
|
||||
)
|
||||
input_data = input_data.astype(expected_dtype)
|
||||
# Ensure input data matches the expected dtype to prevent type mismatches
|
||||
# that can occur with models like Jina-CLIP v2 running on OpenVINO
|
||||
expected_dtype = input_element_type.to_dtype()
|
||||
if input_data.dtype != expected_dtype:
|
||||
logger.debug(
|
||||
f"Converting input '{input_name}' from {input_data.dtype} to {expected_dtype}"
|
||||
)
|
||||
input_data = input_data.astype(expected_dtype)
|
||||
|
||||
input_tensor = ov.Tensor(input_element_type, input_data.shape)
|
||||
np.copyto(input_tensor.data, input_data)
|
||||
input_tensor = ov.Tensor(input_element_type, input_data.shape)
|
||||
np.copyto(input_tensor.data, input_data)
|
||||
|
||||
# Set the input tensor for the specific port index
|
||||
self.infer_request.set_input_tensor(input_index, input_tensor)
|
||||
# Set the input tensor for the specific port index
|
||||
self.infer_request.set_input_tensor(input_index, input_tensor)
|
||||
|
||||
# Run inference
|
||||
self.infer_request.infer()
|
||||
# Run inference
|
||||
self.infer_request.infer()
|
||||
|
||||
# Get all output tensors
|
||||
outputs = []
|
||||
for i in range(len(self.compiled_model.outputs)):
|
||||
outputs.append(self.infer_request.get_output_tensor(i).data)
|
||||
# Get all output tensors
|
||||
outputs = []
|
||||
for i in range(len(self.compiled_model.outputs)):
|
||||
outputs.append(self.infer_request.get_output_tensor(i).data)
|
||||
|
||||
return outputs
|
||||
return outputs
|
||||
|
||||
|
||||
class RKNNModelRunner(BaseModelRunner):
|
||||
@ -500,7 +529,7 @@ def get_optimized_runner(
|
||||
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
|
||||
|
||||
if (
|
||||
not CudaGraphRunner.is_complex_model(model_type)
|
||||
not CudaGraphRunner.is_model_supported(model_type)
|
||||
and providers[0] == "CUDAExecutionProvider"
|
||||
):
|
||||
options[0] = {
|
||||
|
||||
@ -369,6 +369,10 @@ def get_ort_providers(
|
||||
"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:
|
||||
providers.append(provider)
|
||||
options.append({})
|
||||
|
||||
@ -40,7 +40,8 @@
|
||||
"deleteModel": {
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Edit Classification Model",
|
||||
@ -50,11 +51,13 @@
|
||||
},
|
||||
"deleteDatasetImages": {
|
||||
"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": {
|
||||
"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": {
|
||||
"title": "Rename Class",
|
||||
|
||||
@ -2,12 +2,19 @@ import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { CameraConfig } from "@/types/frigateConfig";
|
||||
import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name";
|
||||
|
||||
interface CameraNameLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||
camera?: string | CameraConfig;
|
||||
}
|
||||
|
||||
interface ZoneNameLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
|
||||
zone: string;
|
||||
camera?: string;
|
||||
}
|
||||
|
||||
const CameraNameLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
CameraNameLabelProps
|
||||
@ -21,4 +28,17 @@ const CameraNameLabel = React.forwardRef<
|
||||
});
|
||||
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 };
|
||||
@ -76,7 +76,7 @@ import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
|
||||
import { DialogTrigger } from "@radix-ui/react-dialog";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
|
||||
|
||||
|
||||
@ -190,7 +190,7 @@ export function CamerasFilterContent({
|
||||
key={item}
|
||||
isChecked={currentCameras?.includes(item) ?? false}
|
||||
label={item}
|
||||
isCameraName={true}
|
||||
type={"camera"}
|
||||
disabled={
|
||||
mainCamera !== undefined &&
|
||||
currentCameras !== undefined &&
|
||||
|
||||
@ -1,29 +1,39 @@
|
||||
import { Switch } from "../ui/switch";
|
||||
import { Label } from "../ui/label";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
|
||||
|
||||
type FilterSwitchProps = {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
isChecked: boolean;
|
||||
isCameraName?: boolean;
|
||||
type?: string;
|
||||
extraValue?: string;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
};
|
||||
export default function FilterSwitch({
|
||||
label,
|
||||
disabled = false,
|
||||
isChecked,
|
||||
isCameraName = false,
|
||||
type = "",
|
||||
extraValue = "",
|
||||
onCheckedChange,
|
||||
}: FilterSwitchProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
{isCameraName ? (
|
||||
{type === "camera" ? (
|
||||
<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" : ""}`}
|
||||
htmlFor={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
|
||||
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) => (
|
||||
<FilterSwitch
|
||||
key={item}
|
||||
label={item.replaceAll("_", " ")}
|
||||
label={item}
|
||||
type={"zone"}
|
||||
isChecked={filter.zones?.includes(item) ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
|
||||
@ -53,7 +53,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { MdImageSearch } from "react-icons/md";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
|
||||
|
||||
type InputWithTagsProps = {
|
||||
inputFocused: boolean;
|
||||
@ -831,6 +831,8 @@ export default function InputWithTags({
|
||||
getTranslatedLabel(value)
|
||||
) : filterType === "cameras" ? (
|
||||
<CameraNameLabel camera={value} />
|
||||
) : filterType === "zones" ? (
|
||||
<ZoneNameLabel zone={value} />
|
||||
) : (
|
||||
value.replaceAll("_", " ")
|
||||
)}
|
||||
@ -934,6 +936,11 @@ export default function InputWithTags({
|
||||
<CameraNameLabel camera={suggestion} />
|
||||
{")"}
|
||||
</>
|
||||
) : currentFilterType === "zones" ? (
|
||||
<>
|
||||
{suggestion} {" ("} <ZoneNameLabel zone={suggestion} />
|
||||
{")"}
|
||||
</>
|
||||
) : (
|
||||
suggestion
|
||||
)
|
||||
@ -943,6 +950,8 @@ export default function InputWithTags({
|
||||
{currentFilterType ? (
|
||||
currentFilterType === "cameras" ? (
|
||||
<CameraNameLabel camera={suggestion} />
|
||||
) : currentFilterType === "zones" ? (
|
||||
<ZoneNameLabel zone={suggestion} />
|
||||
) : (
|
||||
formatFilterValues(currentFilterType, suggestion)
|
||||
)
|
||||
|
||||
@ -47,7 +47,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
|
||||
type LiveContextMenuProps = {
|
||||
className?: string;
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
@ -121,17 +122,20 @@ export function MobilePagePortal({
|
||||
type MobilePageContentProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
scrollerRef?: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export function MobilePageContent({
|
||||
children,
|
||||
className,
|
||||
scrollerRef,
|
||||
}: MobilePageContentProps) {
|
||||
const context = useContext(MobilePageContext);
|
||||
if (!context)
|
||||
throw new Error("MobilePageContent must be used within MobilePage");
|
||||
|
||||
const [isVisible, setIsVisible] = useState(context.open);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (context.open) {
|
||||
@ -140,15 +144,27 @@ export function MobilePageContent({
|
||||
}, [context.open]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (context.open && scrollerRef?.current) {
|
||||
scrollerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [context.open, scrollerRef]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 mb-12 bg-background",
|
||||
isPWA && "mb-16",
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
|
||||
@ -24,7 +24,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
|
||||
type EditRoleCamerasOverlayProps = {
|
||||
show: boolean;
|
||||
|
||||
@ -4,7 +4,7 @@ import { Button } from "../ui/button";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CameraNameLabel } from "../camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
|
||||
type MobileCameraDrawerProps = {
|
||||
allCameras: string[];
|
||||
|
||||
@ -12,6 +12,7 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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
|
||||
const TOLERANCE = 0.01;
|
||||
@ -114,6 +115,10 @@ export default function ObjectTrackOverlay({
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const getZonesFriendlyNames = (zones: string[], config: FrigateConfig) => {
|
||||
return zones?.map((zone) => resolveZoneName(config, zone)) ?? [];
|
||||
};
|
||||
|
||||
const timelineResults = useMemo(() => {
|
||||
// Group timeline entries by source_id
|
||||
if (!timelineData) return selectedObjectIds.map(() => []);
|
||||
@ -127,8 +132,19 @@ export default function ObjectTrackOverlay({
|
||||
}
|
||||
|
||||
// Return timeline arrays in the same order as selectedObjectIds
|
||||
return selectedObjectIds.map((id) => grouped[id] || []);
|
||||
}, [selectedObjectIds, timelineData]);
|
||||
return selectedObjectIds.map((id) => {
|
||||
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(
|
||||
() => ({
|
||||
|
||||
@ -55,29 +55,32 @@ export default function DetailActionsMenu({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
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>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
|
||||
download
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{search.has_snapshot && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
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>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{search.has_clip && (
|
||||
<DropdownMenuItem>
|
||||
<a
|
||||
className="w-full"
|
||||
href={`${baseUrl}api/${search.camera}/${clipTimeRange}/clip.mp4`}
|
||||
download
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<span>{t("itemMenu.downloadVideo.label")}</span>
|
||||
</div>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{config?.semantic_search.enabled &&
|
||||
setSimilarity != undefined &&
|
||||
|
||||
@ -8,6 +8,9 @@ import {
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
|
||||
type ObjectPathProps = {
|
||||
positions?: Position[];
|
||||
@ -42,16 +45,31 @@ export function ObjectPath({
|
||||
visible = true,
|
||||
}: ObjectPathProps) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const getAbsolutePositions = useCallback(() => {
|
||||
if (!imgRef.current || !positions) return [];
|
||||
const imgRect = imgRef.current.getBoundingClientRect();
|
||||
return positions.map((pos) => ({
|
||||
x: pos.x * imgRect.width,
|
||||
y: pos.y * imgRect.height,
|
||||
timestamp: pos.timestamp,
|
||||
lifecycle_item: pos.lifecycle_item,
|
||||
}));
|
||||
}, [positions, imgRef]);
|
||||
return positions.map((pos) => {
|
||||
return {
|
||||
x: pos.x * imgRect.width,
|
||||
y: pos.y * imgRect.height,
|
||||
timestamp: pos.timestamp,
|
||||
lifecycle_item: pos.lifecycle_item?.data?.zones
|
||||
? {
|
||||
...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[]) => {
|
||||
if (!points || points.length < 2) return "";
|
||||
|
||||
@ -80,7 +80,7 @@ import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
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 { useDetailStream } from "@/context/detail-stream-context";
|
||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||
@ -306,7 +306,7 @@ function DialogContentComponent({
|
||||
if (page === "tracking_details") {
|
||||
return (
|
||||
<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}
|
||||
tabs={
|
||||
isDesktop ? (
|
||||
@ -584,7 +584,7 @@ export default function SearchDetailDialog({
|
||||
"scrollbar-container overflow-y-auto",
|
||||
isDesktop &&
|
||||
"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) => {
|
||||
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>
|
||||
<Description className="sr-only">
|
||||
{t("trackedObjectDetails")}
|
||||
@ -1078,12 +1078,31 @@ function ObjectDetailsTab({
|
||||
});
|
||||
|
||||
setState("submitted");
|
||||
setSearch({
|
||||
...search,
|
||||
plus_id: "new_upload",
|
||||
});
|
||||
mutate(
|
||||
(key) =>
|
||||
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);
|
||||
@ -1243,8 +1262,8 @@ function ObjectDetailsTab({
|
||||
</div>
|
||||
|
||||
{search.data.type === "object" &&
|
||||
!search.plus_id &&
|
||||
config?.plus?.enabled && (
|
||||
config?.plus?.enabled &&
|
||||
search.has_snapshot && (
|
||||
<div
|
||||
className={cn(
|
||||
"my-2 flex w-full flex-col justify-between gap-1.5",
|
||||
|
||||
@ -23,6 +23,7 @@ import { Link, useNavigate } from "react-router-dom";
|
||||
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import axios from "axios";
|
||||
@ -73,6 +74,12 @@ export function TrackingDetails({
|
||||
|
||||
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
|
||||
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall
|
||||
// back to currentTime used for video mode.
|
||||
@ -345,7 +352,8 @@ export function TrackingDetails({
|
||||
className={cn(
|
||||
isDesktop
|
||||
? "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,
|
||||
)}
|
||||
>
|
||||
@ -712,8 +720,13 @@ function LifecycleIconRow({
|
||||
backgroundColor: `rgb(${color})`,
|
||||
}}
|
||||
/>
|
||||
<span className="smart-capitalize">
|
||||
{zone.replaceAll("_", " ")}
|
||||
<span
|
||||
className={cn(
|
||||
item.data?.zones_friendly_names?.[zidx] === zone &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{item.data?.zones_friendly_names?.[zidx]}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@ -20,7 +20,9 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useRef } from "react";
|
||||
|
||||
type PlatformAwareDialogProps = {
|
||||
trigger: JSX.Element;
|
||||
@ -79,6 +81,8 @@ export function PlatformAwareSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PlatformAwareSheetProps) {
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobilePage open={open} onOpenChange={onOpenChange}>
|
||||
@ -86,14 +90,22 @@ export function PlatformAwareSheet({
|
||||
{trigger}
|
||||
</MobilePageTrigger>
|
||||
<MobilePagePortal>
|
||||
<MobilePageContent className="h-full overflow-hidden">
|
||||
<MobilePageContent
|
||||
className="flex h-full flex-col"
|
||||
scrollerRef={scrollerRef}
|
||||
>
|
||||
<MobilePageHeader
|
||||
className="mx-2"
|
||||
onClose={() => onOpenChange(false)}
|
||||
>
|
||||
<MobilePageTitle>{title}</MobilePageTitle>
|
||||
</MobilePageHeader>
|
||||
<div className={contentClassName}>{content}</div>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className={cn("flex-1 overflow-y-auto", contentClassName)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
</MobilePageContent>
|
||||
</MobilePagePortal>
|
||||
</MobilePage>
|
||||
|
||||
@ -230,6 +230,7 @@ export default function SearchFilterDialog({
|
||||
<PlatformAwareSheet
|
||||
trigger={trigger}
|
||||
title={t("more")}
|
||||
titleClassName="mb-5 -mt-3"
|
||||
content={content}
|
||||
contentClassName={cn(
|
||||
"w-auto lg:min-w-[275px] scrollbar-container h-full overflow-auto px-4",
|
||||
@ -429,7 +430,8 @@ export function ZoneFilterContent({
|
||||
{allZones.map((item) => (
|
||||
<FilterSwitch
|
||||
key={item}
|
||||
label={item.replaceAll("_", " ")}
|
||||
label={item}
|
||||
type={"zone"}
|
||||
isChecked={zones?.includes(item) ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
|
||||
@ -262,13 +262,17 @@ export function PolygonCanvas({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activePolygonIndex === undefined || !polygons) {
|
||||
if (activePolygonIndex === undefined || !polygons?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPolygons = [...polygons];
|
||||
const activePolygon = updatedPolygons[activePolygonIndex];
|
||||
|
||||
if (!activePolygon) {
|
||||
return;
|
||||
}
|
||||
|
||||
// add default points order for already completed polygons
|
||||
if (!activePolygon.pointsOrder && activePolygon.isFinished) {
|
||||
updatedPolygons[activePolygonIndex] = {
|
||||
|
||||
@ -179,7 +179,7 @@ export default function PolygonItem({
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.form.polygonDrawing.delete.success", {
|
||||
name: polygon?.name,
|
||||
name: polygon?.friendly_name ?? polygon?.name,
|
||||
}),
|
||||
{
|
||||
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>
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
@ -278,7 +280,7 @@ export default function PolygonItem({
|
||||
ns="views/settings"
|
||||
values={{
|
||||
type: polygon.type.replace("_", " "),
|
||||
name: polygon.name,
|
||||
name: polygon.friendly_name ?? polygon.name,
|
||||
}}
|
||||
>
|
||||
masksAndZones.form.polygonDrawing.delete.desc
|
||||
|
||||
@ -34,6 +34,7 @@ import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import NameAndIdFields from "../input/NameAndIdFields";
|
||||
|
||||
type ZoneEditPaneProps = {
|
||||
polygons?: Polygon[];
|
||||
@ -146,15 +147,37 @@ export default function ZoneEditPane({
|
||||
"masksAndZones.form.zoneName.error.mustNotContainPeriod",
|
||||
),
|
||||
},
|
||||
)
|
||||
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
|
||||
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
|
||||
})
|
||||
.refine((value: string) => /[a-zA-Z]/.test(value), {
|
||||
),
|
||||
friendly_name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
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
|
||||
.number()
|
||||
.min(1, {
|
||||
@ -247,6 +270,7 @@ export default function ZoneEditPane({
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
name: polygon?.name ?? "",
|
||||
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
|
||||
inertia:
|
||||
polygon?.camera &&
|
||||
polygon?.name &&
|
||||
@ -286,6 +310,7 @@ export default function ZoneEditPane({
|
||||
async (
|
||||
{
|
||||
name: zoneName,
|
||||
friendly_name,
|
||||
inertia,
|
||||
loitering_time,
|
||||
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
|
||||
.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,
|
||||
update_topic: `config/cameras/${polygon.camera}/zones`,
|
||||
@ -427,7 +457,7 @@ export default function ZoneEditPane({
|
||||
if (res.status === 200) {
|
||||
toast.success(
|
||||
t("masksAndZones.zones.toast.success", {
|
||||
zoneName,
|
||||
zoneName: friendly_name || zoneName,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
@ -541,26 +571,17 @@ export default function ZoneEditPane({
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6">
|
||||
<FormField
|
||||
<NameAndIdFields
|
||||
type="zone"
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("masksAndZones.zones.name.title")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
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>
|
||||
)}
|
||||
nameField="friendly_name"
|
||||
idField="name"
|
||||
idVisible={(polygon && polygon.name.length > 0) ?? false}
|
||||
nameLabel={t("masksAndZones.zones.name.title")}
|
||||
nameDescription={t("masksAndZones.zones.name.tips")}
|
||||
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
|
||||
/>
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@ -26,6 +26,7 @@ import { Link } from "react-router-dom";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||
import { MdAutoAwesome } from "react-icons/md";
|
||||
|
||||
@ -793,17 +794,28 @@ function ObjectTimeline({
|
||||
},
|
||||
]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const timeline = useMemo(() => {
|
||||
if (!fullTimeline) {
|
||||
return fullTimeline;
|
||||
}
|
||||
|
||||
return fullTimeline.filter(
|
||||
(t) =>
|
||||
t.timestamp >= review.start_time &&
|
||||
(review.end_time == undefined || t.timestamp <= review.end_time),
|
||||
);
|
||||
}, [fullTimeline, review]);
|
||||
return fullTimeline
|
||||
.filter(
|
||||
(t) =>
|
||||
t.timestamp >= review.start_time &&
|
||||
(review.end_time == undefined || t.timestamp <= review.end_time),
|
||||
)
|
||||
.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)) {
|
||||
return <ActivityIndicator className="ml-2 size-3" />;
|
||||
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import TriggerView from "@/views/settings/TriggerView";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@ -650,7 +650,7 @@ function CameraSelectButton({
|
||||
key={item.name}
|
||||
isChecked={item.name === selectedCamera}
|
||||
label={item.name}
|
||||
isCameraName={true}
|
||||
type={"camera"}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked && (isEnabled || isCameraSettingsPage)) {
|
||||
setSelectedCamera(item.name);
|
||||
|
||||
@ -11,10 +11,12 @@ export type Polygon = {
|
||||
distances: number[];
|
||||
isFinished: boolean;
|
||||
color: number[];
|
||||
friendly_name?: string;
|
||||
};
|
||||
|
||||
export type ZoneFormValuesType = {
|
||||
name: string;
|
||||
friendly_name: string;
|
||||
inertia: number;
|
||||
loitering_time: number;
|
||||
isFinished: boolean;
|
||||
|
||||
@ -280,6 +280,7 @@ export interface CameraConfig {
|
||||
speed_threshold: number;
|
||||
objects: string[];
|
||||
color: number[];
|
||||
friendly_name?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ export type TrackingDetailsSequence = {
|
||||
attribute: string;
|
||||
attribute_box?: [number, number, number, number];
|
||||
zones: string[];
|
||||
zones_friendly_names?: string[];
|
||||
};
|
||||
class_type: LifecycleClassType;
|
||||
source_id: string;
|
||||
|
||||
@ -1,25 +1,7 @@
|
||||
import { TrackingDetailsSequence } from "@/types/timeline";
|
||||
import { t } from "i18next";
|
||||
import { getTranslatedLabel } from "./i18n";
|
||||
import { capitalizeFirstLetter } 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],
|
||||
});
|
||||
}
|
||||
import { capitalizeFirstLetter, formatList } from "./stringUtil";
|
||||
|
||||
export function getLifecycleItemDescription(
|
||||
lifecycleItem: TrackingDetailsSequence,
|
||||
@ -42,7 +24,9 @@ export function getLifecycleItemDescription(
|
||||
return t("trackingDetails.lifecycleItemDesc.entered_zone", {
|
||||
ns: "views/explore",
|
||||
label,
|
||||
zones: formatZonesList(lifecycleItem.data.zones),
|
||||
zones: formatList(
|
||||
lifecycleItem.data.zones_friendly_names ?? lifecycleItem.data.zones,
|
||||
),
|
||||
});
|
||||
case "active":
|
||||
return t("trackingDetails.lifecycleItemDesc.active", {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { t } from "i18next";
|
||||
|
||||
export const capitalizeFirstLetter = (text: string): string => {
|
||||
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)
|
||||
*/
|
||||
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);
|
||||
|
||||
// Convert to base64 manually
|
||||
let binary = "";
|
||||
for (const byte of utf8Bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
// FNV-1a 32-bit hash algorithm
|
||||
let hash = 0x811c9dc5; // FNV offset basis
|
||||
for (let i = 0; i < utf8Bytes.length; i++) {
|
||||
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
|
||||
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
|
||||
// Convert to an 8-character lowercase hex string
|
||||
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 {
|
||||
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],
|
||||
});
|
||||
}
|
||||
|
||||
@ -63,7 +63,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { DetailStreamProvider } from "@/context/detail-stream-context";
|
||||
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";
|
||||
|
||||
@ -36,7 +36,7 @@ import EditRoleCamerasDialog from "@/components/overlay/EditRoleCamerasDialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DeleteRoleDialog from "@/components/overlay/DeleteRoleDialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
|
||||
type AuthenticationViewProps = {
|
||||
section?: "users" | "roles";
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
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 { Trans } from "react-i18next";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
@ -23,7 +23,6 @@ import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import axios from "axios";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@ -42,6 +41,8 @@ import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
|
||||
type CameraSettingsViewProps = {
|
||||
selectedCamera: string;
|
||||
@ -86,11 +87,18 @@ export default function CameraSettingsView({
|
||||
|
||||
// zones and labels
|
||||
|
||||
const getZoneName = useCallback(
|
||||
(zoneId: string, cameraId?: string) =>
|
||||
resolveZoneName(config, zoneId, cameraId),
|
||||
[config],
|
||||
);
|
||||
|
||||
const zones = useMemo(() => {
|
||||
if (cameraConfig) {
|
||||
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
|
||||
camera: cameraConfig.name,
|
||||
name,
|
||||
friendly_name: cameraConfig.zones[name].friendly_name,
|
||||
objects: zoneData.objects,
|
||||
color: zoneData.color,
|
||||
}));
|
||||
@ -99,27 +107,27 @@ export default function CameraSettingsView({
|
||||
|
||||
const alertsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.alerts.labels
|
||||
? cameraConfig.review.alerts.labels
|
||||
.map((label) =>
|
||||
? formatList(
|
||||
cameraConfig.review.alerts.labels.map((label) =>
|
||||
getTranslatedLabel(
|
||||
label,
|
||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||
),
|
||||
)
|
||||
.join(", ")
|
||||
),
|
||||
)
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
|
||||
const detectionsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.detections.labels
|
||||
? cameraConfig.review.detections.labels
|
||||
.map((label) =>
|
||||
? formatList(
|
||||
cameraConfig.review.detections.labels.map((label) =>
|
||||
getTranslatedLabel(
|
||||
label,
|
||||
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
|
||||
),
|
||||
)
|
||||
.join(", ")
|
||||
),
|
||||
)
|
||||
: "";
|
||||
}, [cameraConfig]);
|
||||
|
||||
@ -525,8 +533,14 @@ export default function CameraSettingsView({
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal smart-capitalize">
|
||||
{zone.name.replaceAll("_", " ")}
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"font-normal",
|
||||
!zone.friendly_name &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{zone.friendly_name || zone.name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
@ -548,14 +562,11 @@ export default function CameraSettingsView({
|
||||
"cameraReview.reviewClassification.zoneObjectAlertsTips",
|
||||
{
|
||||
alertsLabels,
|
||||
zone: watchedAlertsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
zone: formatList(
|
||||
watchedAlertsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
},
|
||||
)
|
||||
@ -627,8 +638,14 @@ export default function CameraSettingsView({
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal smart-capitalize">
|
||||
{zone.name.replaceAll("_", " ")}
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"font-normal",
|
||||
!zone.friendly_name &&
|
||||
"smart-capitalize",
|
||||
)}
|
||||
>
|
||||
{zone.friendly_name || zone.name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
@ -667,14 +684,11 @@ export default function CameraSettingsView({
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: watchedDetectionsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
zone: formatList(
|
||||
watchedDetectionsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
@ -684,14 +698,11 @@ export default function CameraSettingsView({
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: watchedDetectionsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
zone: formatList(
|
||||
watchedDetectionsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
|
||||
@ -23,7 +23,7 @@ import {
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
|
||||
type FrigatePlusModel = {
|
||||
id: string;
|
||||
|
||||
@ -229,6 +229,7 @@ export default function MasksAndZonesView({
|
||||
typeIndex: index,
|
||||
camera: cameraConfig.name,
|
||||
name,
|
||||
friendly_name: zoneData.friendly_name,
|
||||
objects: zoneData.objects,
|
||||
points: interpolatePoints(
|
||||
parseCoordinates(zoneData.coordinates),
|
||||
|
||||
@ -45,7 +45,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
@ -476,7 +476,7 @@ export default function NotificationView({
|
||||
<FilterSwitch
|
||||
key={camera.name}
|
||||
label={camera.name}
|
||||
isCameraName={true}
|
||||
type={"camera"}
|
||||
isChecked={field.value?.includes(
|
||||
camera.name,
|
||||
)}
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import useSWR from "swr";
|
||||
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";
|
||||
|
||||
type CameraMetricsProps = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user