Compare commits

..

16 Commits

Author SHA1 Message Date
Josh Hawkins
ab43a33d19 move find similar to actions menu 2025-11-06 21:24:11 -06:00
Josh Hawkins
f3c0c8b61a add trigger to detail actions menu 2025-11-06 18:58:22 -06:00
Josh Hawkins
19e36fc56b ensure name is valid for search effect trigger 2025-11-06 18:54:18 -06:00
Josh Hawkins
6258b90e91 prevent x overflow in detail stream on mobile safari 2025-11-06 18:46:02 -06:00
Josh Hawkins
1d94b24cfe hide x icon on restart sheet to prevent closure issues 2025-11-06 18:02:56 -06:00
Josh Hawkins
294e04adb1 spacing 2025-11-06 17:53:43 -06:00
Josh Hawkins
d4af7f0966 fix audio transcription embedding 2025-11-06 17:38:34 -06:00
Nicolas Mowen
8ddf3ac72e Only show the genai summary popup on mobile when timeline is open 2025-11-06 16:15:38 -07:00
Nicolas Mowen
5b7fe10caf Fix dialog getting stuck 2025-11-06 16:03:36 -07:00
Nicolas Mowen
d8e32b8724 Ensure after creating a class that things are correct 2025-11-06 15:57:42 -07:00
Nicolas Mowen
ca91d3d82b Fix deletion of classification images and library 2025-11-06 15:55:05 -07:00
Josh Hawkins
88b6bd7535 tweak spacing in annotation settings popover 2025-11-06 16:29:27 -06:00
Josh Hawkins
9f73145d63 don't show submit to plus for non-objects and if plus is disabled 2025-11-06 16:25:50 -06:00
Josh Hawkins
f2e3579030 pointer cursor on event menu items in detail stream 2025-11-06 16:22:35 -06:00
Josh Hawkins
796a12bf10 add margin 2025-11-06 16:16:41 -06:00
Josh Hawkins
aa6cd1ec05 remove frigate+ icon from explore grid footer 2025-11-06 16:16:02 -06:00
47 changed files with 239 additions and 546 deletions

View File

@ -5,7 +5,7 @@ title: Enrichments
# Enrichments
Some of Frigate's enrichments can use a discrete GPU or integrated GPU for accelerated processing.
Some of Frigate's enrichments can use a discrete GPU / NPU for accelerated processing.
## Requirements
@ -18,10 +18,8 @@ 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.

View File

@ -261,8 +261,6 @@ 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
@ -285,7 +283,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

View File

@ -810,8 +810,6 @@ 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

View File

@ -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 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 / 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.
```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 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 / NPU 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.

View File

@ -27,7 +27,6 @@ cameras:
- entire_yard
zones:
entire_yard:
friendly_name: Entire yard # You can use characters from any language text
coordinates: ...
```
@ -45,10 +44,8 @@ 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: ...
```
@ -62,7 +59,6 @@ cameras:
- entire_yard
zones:
entire_yard:
friendly_name: Entire yard
coordinates: ...
```
@ -86,7 +82,6 @@ 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.

View File

@ -13,9 +13,6 @@ 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."
)

View File

@ -418,8 +418,8 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
obj_data["box"][2],
obj_data["box"][3],
max(
obj_data["box"][2] - obj_data["box"][0],
obj_data["box"][3] - obj_data["box"][1],
obj_data["box"][1] - obj_data["box"][0],
obj_data["box"][3] - obj_data["box"][2],
),
1.0,
)

View File

@ -3,7 +3,6 @@
import logging
import os
import platform
import threading
from abc import ABC, abstractmethod
from typing import Any
@ -162,12 +161,12 @@ class CudaGraphRunner(BaseModelRunner):
"""
@staticmethod
def is_model_supported(model_type: str) -> bool:
def is_complex_model(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 not in [
return model_type in [
ModelTypeEnum.yolonas.value,
EnrichmentModelTypeEnum.paddleocr.value,
EnrichmentModelTypeEnum.jina_v1.value,
@ -240,30 +239,9 @@ 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):
@ -291,10 +269,6 @@ 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()
@ -338,70 +312,67 @@ class OpenVINOModelRunner(BaseModelRunner):
Returns:
List of output tensors
"""
# 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
# 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):
@ -529,7 +500,7 @@ def get_optimized_runner(
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
if (
not CudaGraphRunner.is_model_supported(model_type)
not CudaGraphRunner.is_complex_model(model_type)
and providers[0] == "CUDAExecutionProvider"
):
options[0] = {

View File

@ -369,10 +369,6 @@ 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({})

View File

@ -40,8 +40,7 @@
"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_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."
"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."
},
"edit": {
"title": "Edit Classification Model",
@ -51,13 +50,11 @@
},
"deleteDatasetImages": {
"title": "Delete Dataset Images",
"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."
"desc": "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_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."
"desc": "Are you sure you want to delete {{count}} images? This action cannot be undone."
},
"renameCategory": {
"title": "Rename Class",

View File

@ -2,19 +2,12 @@ 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
@ -28,17 +21,4 @@ const CameraNameLabel = React.forwardRef<
});
CameraNameLabel.displayName = LabelPrimitive.Root.displayName;
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 };
export { CameraNameLabel };

View File

@ -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/FriendlyNameLabel";
import { CameraNameLabel } from "../camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsCustomRole } from "@/hooks/use-is-custom-role";

View File

@ -190,7 +190,7 @@ export function CamerasFilterContent({
key={item}
isChecked={currentCameras?.includes(item) ?? false}
label={item}
type={"camera"}
isCameraName={true}
disabled={
mainCamera !== undefined &&
currentCameras !== undefined &&

View File

@ -1,39 +1,29 @@
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import { CameraNameLabel, ZoneNameLabel } from "../camera/FriendlyNameLabel";
import { CameraNameLabel } from "../camera/CameraNameLabel";
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,
type = "",
extraValue = "",
isCameraName = false,
onCheckedChange,
}: FilterSwitchProps) {
return (
<div className="flex items-center justify-between gap-1">
{type === "camera" ? (
{isCameraName ? (
<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" : ""}`}

View File

@ -550,8 +550,7 @@ export function GeneralFilterContent({
{allZones.map((item) => (
<FilterSwitch
key={item}
label={item}
type={"zone"}
label={item.replaceAll("_", " ")}
isChecked={filter.zones?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {

View File

@ -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, ZoneNameLabel } from "../camera/FriendlyNameLabel";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type InputWithTagsProps = {
inputFocused: boolean;
@ -831,8 +831,6 @@ export default function InputWithTags({
getTranslatedLabel(value)
) : filterType === "cameras" ? (
<CameraNameLabel camera={value} />
) : filterType === "zones" ? (
<ZoneNameLabel zone={value} />
) : (
value.replaceAll("_", " ")
)}
@ -936,11 +934,6 @@ export default function InputWithTags({
<CameraNameLabel camera={suggestion} />
{")"}
</>
) : currentFilterType === "zones" ? (
<>
{suggestion} {" ("} <ZoneNameLabel zone={suggestion} />
{")"}
</>
) : (
suggestion
)
@ -950,8 +943,6 @@ export default function InputWithTags({
{currentFilterType ? (
currentFilterType === "cameras" ? (
<CameraNameLabel camera={suggestion} />
) : currentFilterType === "zones" ? (
<ZoneNameLabel zone={suggestion} />
) : (
formatFilterValues(currentFilterType, suggestion)
)

View File

@ -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/FriendlyNameLabel";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type LiveContextMenuProps = {
className?: string;

View File

@ -4,7 +4,6 @@ import {
useEffect,
useState,
useCallback,
useRef,
} from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
@ -122,20 +121,17 @@ 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) {
@ -144,27 +140,15 @@ export function MobilePageContent({
}, [context.open]);
const handleAnimationComplete = () => {
if (context.open) {
// After opening animation completes, ensure scroller is at the top
if (scrollerRef?.current) {
scrollerRef.current.scrollTop = 0;
}
} else {
if (!context.open) {
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",

View File

@ -25,7 +25,7 @@ import {
} from "@/components/ui/dialog";
import { useTranslation } from "react-i18next";
import { FrigateConfig } from "@/types/frigateConfig";
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { CameraNameLabel } from "../camera/CameraNameLabel";
import { isDesktop, isMobile } from "react-device-detect";
import { cn } from "@/lib/utils";
import {

View File

@ -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/FriendlyNameLabel";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type EditRoleCamerasOverlayProps = {
show: boolean;

View File

@ -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/FriendlyNameLabel";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type MobileCameraDrawerProps = {
allCameras: string[];

View File

@ -12,7 +12,6 @@ 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;
@ -115,10 +114,6 @@ 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(() => []);
@ -132,19 +127,8 @@ export default function ObjectTrackOverlay({
}
// Return timeline arrays in the same order as selectedObjectIds
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]);
return selectedObjectIds.map((id) => grouped[id] || []);
}, [selectedObjectIds, timelineData]);
const typeColorMap = useMemo(
() => ({

View File

@ -55,32 +55,29 @@ export default function DetailActionsMenu({
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end">
{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>
)}
<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>
{config?.semantic_search.enabled &&
setSimilarity != undefined &&

View File

@ -8,9 +8,6 @@ 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[];
@ -45,31 +42,16 @@ 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) => {
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]);
return positions.map((pos) => ({
x: pos.x * imgRect.width,
y: pos.y * imgRect.height,
timestamp: pos.timestamp,
lifecycle_item: pos.lifecycle_item,
}));
}, [positions, imgRef]);
const generateStraightPath = useCallback((points: Position[]) => {
if (!points || points.length < 2) return "";

View File

@ -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/FriendlyNameLabel";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
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(isDesktop ? "size-full" : "flex flex-col gap-4")}
className={cn("size-full", !isDesktop && "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 && "flex h-full flex-col px-4",
isMobile && "px-4",
)}
onInteractOutside={(e) => {
if (isPopoverOpen) {
@ -596,7 +596,7 @@ export default function SearchDetailDialog({
}
}}
>
<Header className={cn(!isDesktop && "top-0 z-[60] mb-0")}>
<Header>
<Title>{t("trackedObjectDetails")}</Title>
<Description className="sr-only">
{t("trackedObjectDetails")}
@ -1078,31 +1078,12 @@ function ObjectDetailsTab({
});
setState("submitted");
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,
},
);
setSearch({
...search,
plus_id: "new_upload",
});
},
[search, mutate],
[search, setSearch],
);
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
@ -1262,8 +1243,8 @@ function ObjectDetailsTab({
</div>
{search.data.type === "object" &&
config?.plus?.enabled &&
search.has_snapshot && (
!search.plus_id &&
config?.plus?.enabled && (
<div
className={cn(
"my-2 flex w-full flex-col justify-between gap-1.5",

View File

@ -23,7 +23,6 @@ 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";
@ -74,12 +73,6 @@ 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.
@ -352,8 +345,7 @@ export function TrackingDetails({
className={cn(
isDesktop
? "flex size-full justify-evenly gap-4 overflow-hidden"
: "flex flex-col gap-2",
!isDesktop && cameraAspect === "tall" && "size-full",
: "flex size-full flex-col gap-2",
className,
)}
>
@ -720,13 +712,8 @@ function LifecycleIconRow({
backgroundColor: `rgb(${color})`,
}}
/>
<span
className={cn(
item.data?.zones_friendly_names?.[zidx] === zone &&
"smart-capitalize",
)}
>
{item.data?.zones_friendly_names?.[zidx]}
<span className="smart-capitalize">
{zone.replaceAll("_", " ")}
</span>
</Badge>
);

View File

@ -20,9 +20,7 @@ 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;
@ -81,8 +79,6 @@ export function PlatformAwareSheet({
open,
onOpenChange,
}: PlatformAwareSheetProps) {
const scrollerRef = useRef<HTMLDivElement>(null);
if (isMobile) {
return (
<MobilePage open={open} onOpenChange={onOpenChange}>
@ -90,22 +86,14 @@ export function PlatformAwareSheet({
{trigger}
</MobilePageTrigger>
<MobilePagePortal>
<MobilePageContent
className="flex h-full flex-col"
scrollerRef={scrollerRef}
>
<MobilePageContent className="h-full overflow-hidden">
<MobilePageHeader
className="mx-2"
onClose={() => onOpenChange(false)}
>
<MobilePageTitle>{title}</MobilePageTitle>
</MobilePageHeader>
<div
ref={scrollerRef}
className={cn("flex-1 overflow-y-auto", contentClassName)}
>
{content}
</div>
<div className={contentClassName}>{content}</div>
</MobilePageContent>
</MobilePagePortal>
</MobilePage>

View File

@ -230,7 +230,6 @@ 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",
@ -430,8 +429,7 @@ export function ZoneFilterContent({
{allZones.map((item) => (
<FilterSwitch
key={item}
label={item}
type={"zone"}
label={item.replaceAll("_", " ")}
isChecked={zones?.includes(item) ?? false}
onCheckedChange={(isChecked) => {
if (isChecked) {

View File

@ -262,17 +262,13 @@ export function PolygonCanvas({
};
useEffect(() => {
if (activePolygonIndex === undefined || !polygons?.length) {
if (activePolygonIndex === undefined || !polygons) {
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] = {

View File

@ -179,7 +179,7 @@ export default function PolygonItem({
if (res.status === 200) {
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
name: polygon?.name,
}),
{
position: "top-center",
@ -261,9 +261,7 @@ export default function PolygonItem({
}}
/>
)}
<p className="cursor-default">
{polygon.friendly_name ?? polygon.name}
</p>
<p className="cursor-default">{polygon.name}</p>
</div>
<AlertDialog
open={deleteDialogOpen}
@ -280,7 +278,7 @@ export default function PolygonItem({
ns="views/settings"
values={{
type: polygon.type.replace("_", " "),
name: polygon.friendly_name ?? polygon.name,
name: polygon.name,
}}
>
masksAndZones.form.polygonDrawing.delete.desc

View File

@ -34,7 +34,6 @@ 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[];
@ -147,37 +146,15 @@ export default function ZoneEditPane({
"masksAndZones.form.zoneName.error.mustNotContainPeriod",
),
},
),
friendly_name: z
.string()
.min(2, {
message: t(
"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"),
},
),
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
message: t("masksAndZones.form.zoneName.error.hasIllegalCharacter"),
})
.refine((value: string) => /[a-zA-Z]/.test(value), {
message: t(
"masksAndZones.form.zoneName.error.mustHaveAtLeastOneLetter",
),
}),
inertia: z.coerce
.number()
.min(1, {
@ -270,7 +247,6 @@ export default function ZoneEditPane({
mode: "onBlur",
defaultValues: {
name: polygon?.name ?? "",
friendly_name: polygon?.friendly_name ?? polygon?.name ?? "",
inertia:
polygon?.camera &&
polygon?.name &&
@ -310,7 +286,6 @@ export default function ZoneEditPane({
async (
{
name: zoneName,
friendly_name,
inertia,
loitering_time,
objects: form_objects,
@ -440,14 +415,9 @@ 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}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
{
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
@ -457,7 +427,7 @@ export default function ZoneEditPane({
if (res.status === 200) {
toast.success(
t("masksAndZones.zones.toast.success", {
zoneName: friendly_name || zoneName,
zoneName,
}),
{
position: "top-center",
@ -571,17 +541,26 @@ export default function ZoneEditPane({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2 space-y-6">
<NameAndIdFields
type="zone"
<FormField
control={form.control}
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")}
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>
)}
/>
<Separator className="my-2 flex bg-secondary" />
<FormField
control={form.control}

View File

@ -26,7 +26,6 @@ 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";
@ -794,28 +793,17 @@ 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),
)
.map((event) => ({
...event,
data: {
...event.data,
zones_friendly_names: event.data?.zones?.map((zone) =>
resolveZoneName(config, zone),
),
},
}));
}, [config, fullTimeline, review]);
return fullTimeline.filter(
(t) =>
t.timestamp >= review.start_time &&
(review.end_time == undefined || t.timestamp <= review.end_time),
);
}, [fullTimeline, review]);
if (isValidating && (!timeline || timeline.length === 0)) {
return <ActivityIndicator className="ml-2 size-3" />;

View File

@ -1,41 +0,0 @@
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;
}

View File

@ -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/FriendlyNameLabel";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import {
Sidebar,
SidebarContent,
@ -650,7 +650,7 @@ function CameraSelectButton({
key={item.name}
isChecked={item.name === selectedCamera}
label={item.name}
type={"camera"}
isCameraName={true}
onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name);

View File

@ -11,12 +11,10 @@ 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;

View File

@ -280,7 +280,6 @@ export interface CameraConfig {
speed_threshold: number;
objects: string[];
color: number[];
friendly_name?: string;
};
};
}

View File

@ -22,7 +22,6 @@ export type TrackingDetailsSequence = {
attribute: string;
attribute_box?: [number, number, number, number];
zones: string[];
zones_friendly_names?: string[];
};
class_type: LifecycleClassType;
source_id: string;

View File

@ -1,7 +1,25 @@
import { TrackingDetailsSequence } from "@/types/timeline";
import { t } from "i18next";
import { getTranslatedLabel } from "./i18n";
import { capitalizeFirstLetter, formatList } from "./stringUtil";
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],
});
}
export function getLifecycleItemDescription(
lifecycleItem: TrackingDetailsSequence,
@ -24,9 +42,7 @@ export function getLifecycleItemDescription(
return t("trackingDetails.lifecycleItemDesc.entered_zone", {
ns: "views/explore",
label,
zones: formatList(
lifecycleItem.data.zones_friendly_names ?? lifecycleItem.data.zones,
),
zones: formatZonesList(lifecycleItem.data.zones),
});
case "active":
return t("trackingDetails.lifecycleItemDesc.active", {

View File

@ -1,5 +1,3 @@
import { t } from "i18next";
export const capitalizeFirstLetter = (text: string): string => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
@ -21,30 +19,20 @@ 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 {
// 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.
// Safely encode Unicode as UTF-8 bytes
const utf8Bytes = new TextEncoder().encode(name);
// 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;
// Convert to base64 manually
let binary = "";
for (const byte of utf8Bytes) {
binary += String.fromCharCode(byte);
}
const base64 = btoa(binary);
// Convert to an 8-character lowercase hex string
const hashHex = (hash >>> 0).toString(16).padStart(8, "0").toLowerCase();
// Strip out non-alphanumeric characters and truncate
const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8);
// 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}`;
return `${prefix}_${cleanHash.toLowerCase()}`;
}
/**
@ -57,29 +45,3 @@ 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],
});
}

View File

@ -63,7 +63,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { DetailStreamProvider } from "@/context/detail-stream-context";
import { GenAISummaryDialog } from "@/components/overlay/chip/GenAISummaryChip";

View File

@ -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/FriendlyNameLabel";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type AuthenticationViewProps = {
section?: "users" | "roles";

View File

@ -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/FriendlyNameLabel";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { Switch } from "@/components/ui/switch";
import { Trans } from "react-i18next";
import { Separator } from "@/components/ui/separator";

View File

@ -23,6 +23,7 @@ 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";
@ -41,8 +42,6 @@ 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;
@ -87,18 +86,11 @@ 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,
}));
@ -107,27 +99,27 @@ export default function CameraSettingsView({
const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels
? formatList(
cameraConfig.review.alerts.labels.map((label) =>
? 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
? formatList(
cameraConfig.review.detections.labels.map((label) =>
? cameraConfig.review.detections.labels
.map((label) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
)
.join(", ")
: "";
}, [cameraConfig]);
@ -533,14 +525,8 @@ export default function CameraSettingsView({
}}
/>
</FormControl>
<FormLabel
className={cn(
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
<FormLabel className="font-normal smart-capitalize">
{zone.name.replaceAll("_", " ")}
</FormLabel>
</FormItem>
)}
@ -562,11 +548,14 @@ export default function CameraSettingsView({
"cameraReview.reviewClassification.zoneObjectAlertsTips",
{
alertsLabels,
zone: formatList(
watchedAlertsZones.map((zone) =>
getZoneName(zone),
),
),
zone: watchedAlertsZones
.map((zone) =>
capitalizeFirstLetter(zone).replaceAll(
"_",
" ",
),
)
.join(", "),
cameraName: selectCameraName,
},
)
@ -638,14 +627,8 @@ export default function CameraSettingsView({
}}
/>
</FormControl>
<FormLabel
className={cn(
"font-normal",
!zone.friendly_name &&
"smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
<FormLabel className="font-normal smart-capitalize">
{zone.name.replaceAll("_", " ")}
</FormLabel>
</FormItem>
)}
@ -684,11 +667,14 @@ export default function CameraSettingsView({
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{
detectionsLabels,
zone: formatList(
watchedDetectionsZones.map((zone) =>
getZoneName(zone),
),
),
zone: watchedDetectionsZones
.map((zone) =>
capitalizeFirstLetter(zone).replaceAll(
"_",
" ",
),
)
.join(", "),
cameraName: selectCameraName,
}}
ns="views/settings"
@ -698,11 +684,14 @@ export default function CameraSettingsView({
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{
detectionsLabels,
zone: formatList(
watchedDetectionsZones.map((zone) =>
getZoneName(zone),
),
),
zone: watchedDetectionsZones
.map((zone) =>
capitalizeFirstLetter(zone).replaceAll(
"_",
" ",
),
)
.join(", "),
cameraName: selectCameraName,
}}
ns="views/settings"

View File

@ -23,7 +23,7 @@ import {
SelectTrigger,
} from "@/components/ui/select";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type FrigatePlusModel = {
id: string;

View File

@ -229,7 +229,6 @@ export default function MasksAndZonesView({
typeIndex: index,
camera: cameraConfig.name,
name,
friendly_name: zoneData.friendly_name,
objects: zoneData.objects,
points: interpolatePoints(
parseCoordinates(zoneData.coordinates),

View File

@ -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/FriendlyNameLabel";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
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}
type={"camera"}
isCameraName={true}
isChecked={field.value?.includes(
camera.name,
)}

View File

@ -13,7 +13,7 @@ import {
} from "@/components/ui/tooltip";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
type CameraMetricsProps = {