mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
28 Commits
ab4d9c1d34
...
ab55c0b286
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab55c0b286 | ||
|
|
91b267a2b6 | ||
|
|
06e2fc0aa0 | ||
|
|
a510ea9036 | ||
|
|
90b7086b1e | ||
|
|
79f0570511 | ||
|
|
e1bc7360ad | ||
|
|
4638c22c16 | ||
|
|
cb8d80f8a2 | ||
|
|
4478fa4217 | ||
|
|
69081ae328 | ||
|
|
5768be41c8 | ||
|
|
81faa8899d | ||
|
|
043bd9e6ee | ||
|
|
b6bb895d79 | ||
|
|
ca901f492f | ||
|
|
54f228fb6f | ||
|
|
9f0b6004f2 | ||
|
|
b751228476 | ||
|
|
3b2d136665 | ||
|
|
303ebff4e3 | ||
|
|
e7394d0dc1 | ||
|
|
3af47d63ea | ||
|
|
2e288109f4 | ||
|
|
b68ddc39c4 | ||
|
|
136836fe86 | ||
|
|
894f9d06dd | ||
|
|
2339b5f35d |
@ -5,21 +5,27 @@ set -euxo pipefail
|
||||
SQLITE3_VERSION="3.46.1"
|
||||
PYSQLITE3_VERSION="0.5.3"
|
||||
|
||||
# Install libsqlite3-dev if not present (needed for some base images like NVIDIA TensorRT)
|
||||
if ! dpkg -l | grep -q libsqlite3-dev; then
|
||||
echo "Installing libsqlite3-dev for compilation..."
|
||||
apt-get update && apt-get install -y libsqlite3-dev && rm -rf /var/lib/apt/lists/*
|
||||
fi
|
||||
|
||||
# Fetch the pre-built sqlite amalgamation instead of building from source
|
||||
if [[ ! -d "sqlite" ]]; then
|
||||
mkdir sqlite
|
||||
cd sqlite
|
||||
|
||||
|
||||
# Download the pre-built amalgamation from sqlite.org
|
||||
# For SQLite 3.46.1, the amalgamation version is 3460100
|
||||
SQLITE_AMALGAMATION_VERSION="3460100"
|
||||
|
||||
|
||||
wget https://www.sqlite.org/2024/sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}.zip -O sqlite-amalgamation.zip
|
||||
unzip sqlite-amalgamation.zip
|
||||
mv sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}/* .
|
||||
rmdir sqlite-amalgamation-${SQLITE_AMALGAMATION_VERSION}
|
||||
rm sqlite-amalgamation.zip
|
||||
|
||||
|
||||
cd ../
|
||||
fi
|
||||
|
||||
|
||||
@ -112,7 +112,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y protobuf-compiler libprotobuf-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN --mount=type=bind,source=docker/tensorrt/requirements-models-arm64.txt,target=/requirements-tensorrt-models.txt \
|
||||
pip3 wheel --wheel-dir=/trt-model-wheels -r /requirements-tensorrt-models.txt
|
||||
pip3 wheel --wheel-dir=/trt-model-wheels --no-deps -r /requirements-tensorrt-models.txt
|
||||
|
||||
FROM wget AS jetson-ffmpeg
|
||||
ARG DEBIAN_FRONTEND
|
||||
@ -145,7 +145,8 @@ COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER
|
||||
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
|
||||
--mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
|
||||
pip3 uninstall -y onnxruntime \
|
||||
&& pip3 install -U /deps/trt-wheels/*.whl /deps/trt-model-wheels/*.whl \
|
||||
&& pip3 install -U /deps/trt-wheels/*.whl \
|
||||
&& pip3 install -U /deps/trt-model-wheels/*.whl \
|
||||
&& ldconfig
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -38,7 +38,7 @@ from frigate.util.classification import (
|
||||
collect_object_classification_examples,
|
||||
collect_state_classification_examples,
|
||||
)
|
||||
from frigate.util.path import get_event_snapshot
|
||||
from frigate.util.file import get_event_snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -58,7 +58,7 @@ from frigate.const import CLIPS_DIR, TRIGGER_DIR
|
||||
from frigate.embeddings import EmbeddingsContext
|
||||
from frigate.models import Event, ReviewSegment, Timeline, Trigger
|
||||
from frigate.track.object_processing import TrackedObject
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
from frigate.util.time import get_dst_transitions, get_tz_modifiers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -44,8 +44,8 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
|
||||
from frigate.track.object_processing import TrackedObjectProcessor
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
from frigate.util.image import get_image_from_recording
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
from frigate.util.time import get_dst_transitions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -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."
|
||||
)
|
||||
|
||||
@ -20,8 +20,8 @@ from frigate.genai import GenAIClient
|
||||
from frigate.models import Event
|
||||
from frigate.types import TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
from frigate.util.image import create_thumbnail, ensure_jpeg_bytes
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frigate.embeddings import Embeddings
|
||||
|
||||
@ -22,7 +22,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.embeddings.util import ZScoreNormalization
|
||||
from frigate.models import Event, Trigger
|
||||
from frigate.util.builtin import cosine_distance
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
|
||||
from ..post.api import PostProcessorApi
|
||||
from ..types import DataProcessorMetrics
|
||||
|
||||
@ -466,6 +466,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
||||
now,
|
||||
self.labelmap[best_id],
|
||||
score,
|
||||
max_files=200,
|
||||
)
|
||||
|
||||
if score < self.model_config.threshold:
|
||||
@ -529,6 +530,7 @@ def write_classification_attempt(
|
||||
timestamp: float,
|
||||
label: str,
|
||||
score: float,
|
||||
max_files: int = 100,
|
||||
) -> None:
|
||||
if "-" in label:
|
||||
label = label.replace("-", "_")
|
||||
@ -544,5 +546,5 @@ def write_classification_attempt(
|
||||
)
|
||||
|
||||
# delete oldest face image if maximum is reached
|
||||
if len(files) > 100:
|
||||
if len(files) > max_files:
|
||||
os.unlink(os.path.join(folder, files[-1]))
|
||||
|
||||
@ -17,6 +17,7 @@ from frigate.detectors.detector_config import (
|
||||
BaseDetectorConfig,
|
||||
ModelTypeEnum,
|
||||
)
|
||||
from frigate.util.file import FileLock
|
||||
from frigate.util.model import post_process_yolo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -177,29 +178,6 @@ class MemryXDetector(DetectionApi):
|
||||
logger.error(f"Failed to initialize MemryX model: {e}")
|
||||
raise
|
||||
|
||||
def _acquire_file_lock(self, lock_path: str, timeout: int = 60, poll: float = 0.2):
|
||||
"""
|
||||
Create an exclusive lock file. Blocks (with polling) until it can acquire,
|
||||
or raises TimeoutError. Uses only stdlib (os.O_EXCL).
|
||||
"""
|
||||
start = time.time()
|
||||
while True:
|
||||
try:
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
os.close(fd)
|
||||
return
|
||||
except FileExistsError:
|
||||
if time.time() - start > timeout:
|
||||
raise TimeoutError(f"Timeout waiting for lock: {lock_path}")
|
||||
time.sleep(poll)
|
||||
|
||||
def _release_file_lock(self, lock_path: str):
|
||||
"""Best-effort removal of the lock file."""
|
||||
try:
|
||||
os.remove(lock_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def load_yolo_constants(self):
|
||||
base = f"{self.cache_dir}/{self.model_folder}"
|
||||
# constants for yolov9 post-processing
|
||||
@ -212,9 +190,9 @@ class MemryXDetector(DetectionApi):
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
|
||||
lock_path = os.path.join(self.cache_dir, f".{self.model_folder}.lock")
|
||||
self._acquire_file_lock(lock_path)
|
||||
lock = FileLock(lock_path, timeout=60)
|
||||
|
||||
try:
|
||||
with lock:
|
||||
# ---------- CASE 1: user provided a custom model path ----------
|
||||
if self.memx_model_path:
|
||||
if not self.memx_model_path.endswith(".zip"):
|
||||
@ -338,9 +316,6 @@ class MemryXDetector(DetectionApi):
|
||||
f"Failed to remove downloaded zip {zip_path}: {e}"
|
||||
)
|
||||
|
||||
finally:
|
||||
self._release_file_lock(lock_path)
|
||||
|
||||
def send_input(self, connection_id, tensor_input: np.ndarray):
|
||||
"""Pre-process (if needed) and send frame to MemryX input queue"""
|
||||
if tensor_input is None:
|
||||
|
||||
@ -29,7 +29,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.models import Event, Trigger
|
||||
from frigate.types import ModelStatusTypesEnum
|
||||
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
|
||||
from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding
|
||||
from .onnx.jina_v2_embedding import JinaV2Embedding
|
||||
|
||||
@ -62,8 +62,8 @@ from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
|
||||
from frigate.genai import get_genai_client
|
||||
from frigate.models import Event, Recordings, ReviewSegment, Trigger
|
||||
from frigate.util.builtin import serialize
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
from frigate.util.image import SharedMemoryFrameManager
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
|
||||
from .embeddings import Embeddings
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ from frigate.config import FrigateConfig
|
||||
from frigate.const import CLIPS_DIR
|
||||
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
|
||||
from frigate.models import Event, Timeline
|
||||
from frigate.util.path import delete_event_snapshot, delete_event_thumbnail
|
||||
from frigate.util.file import delete_event_snapshot, delete_event_thumbnail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -131,6 +131,19 @@ class TimelineProcessor(threading.Thread):
|
||||
):
|
||||
timeline_entry[Timeline.class_type] = "entered_zone"
|
||||
timeline_entry[Timeline.data]["zones"] = event_data["current_zones"]
|
||||
|
||||
zones_friendly = []
|
||||
for z in event_data["current_zones"]:
|
||||
zone_cfg = camera_config.zones.get(z)
|
||||
if zone_cfg is not None and getattr(
|
||||
zone_cfg, "friendly_name", None
|
||||
):
|
||||
zones_friendly.append(zone_cfg.friendly_name)
|
||||
else:
|
||||
zones_friendly.append(z)
|
||||
|
||||
timeline_entry[Timeline.data]["zones_friendly_names"] = zones_friendly
|
||||
|
||||
save = True
|
||||
elif prev_event_data["stationary"] != event_data["stationary"]:
|
||||
timeline_entry[Timeline.class_type] = (
|
||||
|
||||
@ -20,8 +20,8 @@ from frigate.const import (
|
||||
from frigate.log import redirect_output_to_logger
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.types import ModelStatusTypesEnum
|
||||
from frigate.util.file import get_event_thumbnail_bytes
|
||||
from frigate.util.image import get_image_from_recording
|
||||
from frigate.util.path import get_event_thumbnail_bytes
|
||||
from frigate.util.process import FrigateProcess
|
||||
|
||||
BATCH_SIZE = 16
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, List
|
||||
|
||||
@ -10,40 +9,11 @@ import requests
|
||||
from frigate.comms.inter_process import InterProcessRequestor
|
||||
from frigate.const import UPDATE_MODEL_STATE
|
||||
from frigate.types import ModelStatusTypesEnum
|
||||
from frigate.util.file import FileLock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileLock:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.lock_file = f"{path}.lock"
|
||||
|
||||
# we have not acquired the lock yet so it should not exist
|
||||
if os.path.exists(self.lock_file):
|
||||
try:
|
||||
os.remove(self.lock_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def acquire(self):
|
||||
parent_dir = os.path.dirname(self.lock_file)
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
while True:
|
||||
try:
|
||||
with open(self.lock_file, "x"):
|
||||
return
|
||||
except FileExistsError:
|
||||
time.sleep(0.1)
|
||||
|
||||
def release(self):
|
||||
try:
|
||||
os.remove(self.lock_file)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
class ModelDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
@ -81,15 +51,13 @@ class ModelDownloader:
|
||||
def _download_models(self):
|
||||
for file_name in self.file_names:
|
||||
path = os.path.join(self.download_path, file_name)
|
||||
lock = FileLock(path)
|
||||
lock_path = f"{path}.lock"
|
||||
lock = FileLock(lock_path, cleanup_stale_on_init=True)
|
||||
|
||||
if not os.path.exists(path):
|
||||
lock.acquire()
|
||||
try:
|
||||
with lock:
|
||||
if not os.path.exists(path):
|
||||
self.download_func(path)
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
self.requestor.send_data(
|
||||
UPDATE_MODEL_STATE,
|
||||
|
||||
276
frigate/util/file.py
Normal file
276
frigate/util/file.py
Normal file
@ -0,0 +1,276 @@
|
||||
"""Path and file utilities."""
|
||||
|
||||
import base64
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import cv2
|
||||
from numpy import ndarray
|
||||
|
||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||
from frigate.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_event_thumbnail_bytes(event: Event) -> bytes | None:
|
||||
if event.thumbnail:
|
||||
return base64.b64decode(event.thumbnail)
|
||||
else:
|
||||
try:
|
||||
with open(
|
||||
os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp"), "rb"
|
||||
) as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_event_snapshot(event: Event) -> ndarray:
|
||||
media_name = f"{event.camera}-{event.id}"
|
||||
return cv2.imread(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||
|
||||
|
||||
### Deletion
|
||||
|
||||
|
||||
def delete_event_images(event: Event) -> bool:
|
||||
return delete_event_snapshot(event) and delete_event_thumbnail(event)
|
||||
|
||||
|
||||
def delete_event_snapshot(event: Event) -> bool:
|
||||
media_name = f"{event.camera}-{event.id}"
|
||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||
|
||||
try:
|
||||
media_path.unlink(missing_ok=True)
|
||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.webp")
|
||||
media_path.unlink(missing_ok=True)
|
||||
# also delete clean.png (legacy) for backward compatibility
|
||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
|
||||
media_path.unlink(missing_ok=True)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def delete_event_thumbnail(event: Event) -> bool:
|
||||
if event.thumbnail:
|
||||
return True
|
||||
else:
|
||||
Path(os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp")).unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
### File Locking
|
||||
|
||||
|
||||
class FileLock:
|
||||
"""
|
||||
A file-based lock for coordinating access to resources across processes.
|
||||
|
||||
Uses fcntl.flock() for proper POSIX file locking on Linux. Supports timeouts,
|
||||
stale lock detection, and can be used as a context manager.
|
||||
|
||||
Example:
|
||||
```python
|
||||
# Using as a context manager (recommended)
|
||||
with FileLock("/path/to/resource.lock", timeout=60):
|
||||
# Critical section
|
||||
do_something()
|
||||
|
||||
# Manual acquisition and release
|
||||
lock = FileLock("/path/to/resource.lock")
|
||||
if lock.acquire(timeout=60):
|
||||
try:
|
||||
do_something()
|
||||
finally:
|
||||
lock.release()
|
||||
```
|
||||
|
||||
Attributes:
|
||||
lock_path: Path to the lock file
|
||||
timeout: Maximum time to wait for lock acquisition (seconds)
|
||||
poll_interval: Time to wait between lock acquisition attempts (seconds)
|
||||
stale_timeout: Time after which a lock is considered stale (seconds)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lock_path: str | Path,
|
||||
timeout: int = 300,
|
||||
poll_interval: float = 1.0,
|
||||
stale_timeout: int = 600,
|
||||
cleanup_stale_on_init: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize a FileLock.
|
||||
|
||||
Args:
|
||||
lock_path: Path to the lock file
|
||||
timeout: Maximum time to wait for lock acquisition in seconds (default: 300)
|
||||
poll_interval: Time to wait between lock attempts in seconds (default: 1.0)
|
||||
stale_timeout: Time after which a lock is considered stale in seconds (default: 600)
|
||||
cleanup_stale_on_init: Whether to clean up stale locks on initialization (default: False)
|
||||
"""
|
||||
self.lock_path = Path(lock_path)
|
||||
self.timeout = timeout
|
||||
self.poll_interval = poll_interval
|
||||
self.stale_timeout = stale_timeout
|
||||
self._fd: Optional[int] = None
|
||||
self._acquired = False
|
||||
|
||||
if cleanup_stale_on_init:
|
||||
self._cleanup_stale_lock()
|
||||
|
||||
def _cleanup_stale_lock(self) -> bool:
|
||||
"""
|
||||
Clean up a stale lock file if it exists and is old.
|
||||
|
||||
Returns:
|
||||
True if lock was cleaned up, False otherwise
|
||||
"""
|
||||
try:
|
||||
if self.lock_path.exists():
|
||||
# Check if lock file is older than stale_timeout
|
||||
lock_age = time.time() - self.lock_path.stat().st_mtime
|
||||
if lock_age > self.stale_timeout:
|
||||
logger.warning(
|
||||
f"Removing stale lock file: {self.lock_path} (age: {lock_age:.1f}s)"
|
||||
)
|
||||
self.lock_path.unlink()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up stale lock: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def is_stale(self) -> bool:
|
||||
"""
|
||||
Check if the lock file is stale (older than stale_timeout).
|
||||
|
||||
Returns:
|
||||
True if lock is stale, False otherwise
|
||||
"""
|
||||
try:
|
||||
if self.lock_path.exists():
|
||||
lock_age = time.time() - self.lock_path.stat().st_mtime
|
||||
return lock_age > self.stale_timeout
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def acquire(self, timeout: Optional[int] = None) -> bool:
|
||||
"""
|
||||
Acquire the file lock using fcntl.flock().
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait for lock in seconds (uses instance timeout if None)
|
||||
|
||||
Returns:
|
||||
True if lock acquired, False if timeout or error
|
||||
"""
|
||||
if self._acquired:
|
||||
logger.warning(f"Lock already acquired: {self.lock_path}")
|
||||
return True
|
||||
|
||||
if timeout is None:
|
||||
timeout = self.timeout
|
||||
|
||||
# Ensure parent directory exists
|
||||
self.lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clean up stale lock before attempting to acquire
|
||||
self._cleanup_stale_lock()
|
||||
|
||||
try:
|
||||
self._fd = os.open(self.lock_path, os.O_CREAT | os.O_RDWR)
|
||||
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
self._acquired = True
|
||||
logger.debug(f"Acquired lock: {self.lock_path}")
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
# Lock is held by another process
|
||||
if time.time() - start_time >= timeout:
|
||||
logger.warning(f"Timeout waiting for lock: {self.lock_path}")
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
return False
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
# Timeout reached
|
||||
if self._fd is not None:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error acquiring lock: {e}")
|
||||
if self._fd is not None:
|
||||
try:
|
||||
os.close(self._fd)
|
||||
except Exception:
|
||||
pass
|
||||
self._fd = None
|
||||
return False
|
||||
|
||||
def release(self) -> None:
|
||||
"""
|
||||
Release the file lock.
|
||||
|
||||
This closes the file descriptor and removes the lock file.
|
||||
"""
|
||||
if not self._acquired:
|
||||
return
|
||||
|
||||
try:
|
||||
# Close file descriptor and release fcntl lock
|
||||
if self._fd is not None:
|
||||
try:
|
||||
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
||||
os.close(self._fd)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing lock file descriptor: {e}")
|
||||
finally:
|
||||
self._fd = None
|
||||
|
||||
# Remove lock file
|
||||
if self.lock_path.exists():
|
||||
self.lock_path.unlink()
|
||||
logger.debug(f"Released lock: {self.lock_path}")
|
||||
|
||||
except FileNotFoundError:
|
||||
# Lock file already removed, that's fine
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing lock: {e}")
|
||||
finally:
|
||||
self._acquired = False
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry - acquire the lock."""
|
||||
if not self.acquire():
|
||||
raise TimeoutError(f"Failed to acquire lock: {self.lock_path}")
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit - release the lock."""
|
||||
self.release()
|
||||
return False
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor - ensure lock is released."""
|
||||
if self._acquired:
|
||||
self.release()
|
||||
@ -1,62 +0,0 @@
|
||||
"""Path utilities."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
from numpy import ndarray
|
||||
|
||||
from frigate.const import CLIPS_DIR, THUMB_DIR
|
||||
from frigate.models import Event
|
||||
|
||||
|
||||
def get_event_thumbnail_bytes(event: Event) -> bytes | None:
|
||||
if event.thumbnail:
|
||||
return base64.b64decode(event.thumbnail)
|
||||
else:
|
||||
try:
|
||||
with open(
|
||||
os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp"), "rb"
|
||||
) as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_event_snapshot(event: Event) -> ndarray:
|
||||
media_name = f"{event.camera}-{event.id}"
|
||||
return cv2.imread(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||
|
||||
|
||||
### Deletion
|
||||
|
||||
|
||||
def delete_event_images(event: Event) -> bool:
|
||||
return delete_event_snapshot(event) and delete_event_thumbnail(event)
|
||||
|
||||
|
||||
def delete_event_snapshot(event: Event) -> bool:
|
||||
media_name = f"{event.camera}-{event.id}"
|
||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||
|
||||
try:
|
||||
media_path.unlink(missing_ok=True)
|
||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.webp")
|
||||
media_path.unlink(missing_ok=True)
|
||||
# also delete clean.png (legacy) for backward compatibility
|
||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
|
||||
media_path.unlink(missing_ok=True)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def delete_event_thumbnail(event: Event) -> bool:
|
||||
if event.thumbnail:
|
||||
return True
|
||||
else:
|
||||
Path(os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp")).unlink(
|
||||
missing_ok=True
|
||||
)
|
||||
return True
|
||||
@ -1,6 +1,5 @@
|
||||
"""RKNN model conversion utility for Frigate."""
|
||||
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
@ -9,6 +8,8 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from frigate.util.file import FileLock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODEL_TYPE_CONFIGS = {
|
||||
@ -245,112 +246,6 @@ def convert_onnx_to_rknn(
|
||||
logger.warning(f"Failed to remove temporary ONNX file: {e}")
|
||||
|
||||
|
||||
def cleanup_stale_lock(lock_file_path: Path) -> bool:
|
||||
"""
|
||||
Clean up a stale lock file if it exists and is old.
|
||||
|
||||
Args:
|
||||
lock_file_path: Path to the lock file
|
||||
|
||||
Returns:
|
||||
True if lock was cleaned up, False otherwise
|
||||
"""
|
||||
try:
|
||||
if lock_file_path.exists():
|
||||
# Check if lock file is older than 10 minutes (stale)
|
||||
lock_age = time.time() - lock_file_path.stat().st_mtime
|
||||
if lock_age > 600: # 10 minutes
|
||||
logger.warning(
|
||||
f"Removing stale lock file: {lock_file_path} (age: {lock_age:.1f}s)"
|
||||
)
|
||||
lock_file_path.unlink()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up stale lock: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def acquire_conversion_lock(lock_file_path: Path, timeout: int = 300) -> bool:
|
||||
"""
|
||||
Acquire a file-based lock for model conversion.
|
||||
|
||||
Args:
|
||||
lock_file_path: Path to the lock file
|
||||
timeout: Maximum time to wait for lock in seconds
|
||||
|
||||
Returns:
|
||||
True if lock acquired, False if timeout or error
|
||||
"""
|
||||
try:
|
||||
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cleanup_stale_lock(lock_file_path)
|
||||
lock_fd = os.open(lock_file_path, os.O_CREAT | os.O_RDWR)
|
||||
|
||||
# Try to acquire exclusive lock
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
# Lock acquired successfully
|
||||
logger.debug(f"Acquired conversion lock: {lock_file_path}")
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
# Lock is held by another process, wait and retry
|
||||
if time.time() - start_time >= timeout:
|
||||
logger.warning(
|
||||
f"Timeout waiting for conversion lock: {lock_file_path}"
|
||||
)
|
||||
os.close(lock_fd)
|
||||
return False
|
||||
|
||||
logger.debug("Waiting for conversion lock to be released...")
|
||||
time.sleep(1)
|
||||
|
||||
os.close(lock_fd)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error acquiring conversion lock: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def release_conversion_lock(lock_file_path: Path) -> None:
|
||||
"""
|
||||
Release the conversion lock.
|
||||
|
||||
Args:
|
||||
lock_file_path: Path to the lock file
|
||||
"""
|
||||
try:
|
||||
if lock_file_path.exists():
|
||||
lock_file_path.unlink()
|
||||
logger.debug(f"Released conversion lock: {lock_file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing conversion lock: {e}")
|
||||
|
||||
|
||||
def is_lock_stale(lock_file_path: Path, max_age: int = 600) -> bool:
|
||||
"""
|
||||
Check if a lock file is stale (older than max_age seconds).
|
||||
|
||||
Args:
|
||||
lock_file_path: Path to the lock file
|
||||
max_age: Maximum age in seconds before considering lock stale
|
||||
|
||||
Returns:
|
||||
True if lock is stale, False otherwise
|
||||
"""
|
||||
try:
|
||||
if lock_file_path.exists():
|
||||
lock_age = time.time() - lock_file_path.stat().st_mtime
|
||||
return lock_age > max_age
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def wait_for_conversion_completion(
|
||||
model_type: str, rknn_path: Path, lock_file_path: Path, timeout: int = 300
|
||||
) -> bool:
|
||||
@ -358,6 +253,7 @@ def wait_for_conversion_completion(
|
||||
Wait for another process to complete the conversion.
|
||||
|
||||
Args:
|
||||
model_type: Type of model being converted
|
||||
rknn_path: Path to the expected RKNN model
|
||||
lock_file_path: Path to the lock file to monitor
|
||||
timeout: Maximum time to wait in seconds
|
||||
@ -366,6 +262,8 @@ def wait_for_conversion_completion(
|
||||
True if RKNN model appears, False if timeout
|
||||
"""
|
||||
start_time = time.time()
|
||||
lock = FileLock(lock_file_path, stale_timeout=600)
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
# Check if RKNN model appeared
|
||||
if rknn_path.exists():
|
||||
@ -385,11 +283,14 @@ def wait_for_conversion_completion(
|
||||
return False
|
||||
|
||||
# Check if lock is stale
|
||||
if is_lock_stale(lock_file_path):
|
||||
if lock.is_stale():
|
||||
logger.warning("Lock file is stale, attempting to clean up and retry...")
|
||||
cleanup_stale_lock(lock_file_path)
|
||||
lock._cleanup_stale_lock()
|
||||
# Try to acquire lock again
|
||||
if acquire_conversion_lock(lock_file_path, timeout=60):
|
||||
retry_lock = FileLock(
|
||||
lock_file_path, timeout=60, cleanup_stale_on_init=True
|
||||
)
|
||||
if retry_lock.acquire():
|
||||
try:
|
||||
# Check if RKNN file appeared while waiting
|
||||
if rknn_path.exists():
|
||||
@ -415,7 +316,7 @@ def wait_for_conversion_completion(
|
||||
return False
|
||||
|
||||
finally:
|
||||
release_conversion_lock(lock_file_path)
|
||||
retry_lock.release()
|
||||
|
||||
logger.debug("Waiting for RKNN model to appear...")
|
||||
time.sleep(1)
|
||||
@ -452,8 +353,9 @@ def auto_convert_model(
|
||||
return str(rknn_path)
|
||||
|
||||
lock_file_path = base_path.parent / f"{base_name}.conversion.lock"
|
||||
lock = FileLock(lock_file_path, timeout=300, cleanup_stale_on_init=True)
|
||||
|
||||
if acquire_conversion_lock(lock_file_path):
|
||||
if lock.acquire():
|
||||
try:
|
||||
if rknn_path.exists():
|
||||
logger.info(
|
||||
@ -476,7 +378,7 @@ def auto_convert_model(
|
||||
return None
|
||||
|
||||
finally:
|
||||
release_conversion_lock(lock_file_path)
|
||||
lock.release()
|
||||
else:
|
||||
logger.info(
|
||||
f"Another process is converting {model_path}, waiting for completion..."
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
{
|
||||
"documentTitle": "Classification Models",
|
||||
"details": {
|
||||
"scoreInfo": "Score represents the average classification confidence across all detections of this object."
|
||||
},
|
||||
"button": {
|
||||
"deleteClassificationAttempts": "Delete Classification Images",
|
||||
"renameCategory": "Rename Class",
|
||||
@ -7,23 +10,27 @@
|
||||
"deleteImages": "Delete Images",
|
||||
"trainModel": "Train Model",
|
||||
"addClassification": "Add Classification",
|
||||
"deleteModels": "Delete Models"
|
||||
"deleteModels": "Delete Models",
|
||||
"editModel": "Edit Model"
|
||||
},
|
||||
"toast": {
|
||||
"success": {
|
||||
"deletedCategory": "Deleted Class",
|
||||
"deletedImage": "Deleted Images",
|
||||
"deletedModel": "Successfully deleted {{count}} model(s)",
|
||||
"deletedModel_one": "Successfully deleted {{count}} model",
|
||||
"deletedModel_other": "Successfully deleted {{count}} models",
|
||||
"categorizedImage": "Successfully Classified Image",
|
||||
"trainedModel": "Successfully trained model.",
|
||||
"trainingModel": "Successfully started model training."
|
||||
"trainingModel": "Successfully started model training.",
|
||||
"updatedModel": "Successfully updated model configuration"
|
||||
},
|
||||
"error": {
|
||||
"deleteImageFailed": "Failed to delete: {{errorMessage}}",
|
||||
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
|
||||
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
|
||||
"categorizeFailed": "Failed to categorize image: {{errorMessage}}",
|
||||
"trainingFailed": "Failed to start model training: {{errorMessage}}"
|
||||
"trainingFailed": "Failed to start model training: {{errorMessage}}",
|
||||
"updateModelFailed": "Failed to update model: {{errorMessage}}"
|
||||
}
|
||||
},
|
||||
"deleteCategory": {
|
||||
@ -35,6 +42,12 @@
|
||||
"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."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit Classification Model",
|
||||
"descriptionState": "Edit the classes for this state classification model. Changes will require retraining the model.",
|
||||
"descriptionObject": "Edit the object type and classification type for this object classification model.",
|
||||
"stateClassesInfo": "Note: Changing state classes requires retraining the model with the updated classes."
|
||||
},
|
||||
"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."
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
},
|
||||
"details": {
|
||||
"timestamp": "Timestamp",
|
||||
"unknown": "Unknown"
|
||||
"unknown": "Unknown",
|
||||
"scoreInfo": "Score is a weighted average of all face scores, weighted by the size of the face in each image."
|
||||
},
|
||||
"documentTitle": "Face Library - Frigate",
|
||||
"uploadFaceImage": {
|
||||
|
||||
@ -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 };
|
||||
@ -7,11 +7,12 @@ import {
|
||||
} from "@/types/classification";
|
||||
import { Event } from "@/types/event";
|
||||
import { forwardRef, useMemo, useRef, useState } from "react";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TimeAgo from "../dynamic/TimeAgo";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { LuSearch } from "react-icons/lu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { LuSearch, LuInfo } from "react-icons/lu";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { HiSquare2Stack } from "react-icons/hi2";
|
||||
@ -263,8 +264,8 @@ export function GroupedClassificationCard({
|
||||
|
||||
const Overlay = isDesktop ? Dialog : MobilePage;
|
||||
const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger;
|
||||
const Header = isDesktop ? DialogHeader : MobilePageHeader;
|
||||
const Content = isDesktop ? DialogContent : MobilePageContent;
|
||||
const Header = isDesktop ? DialogHeader : MobilePageHeader;
|
||||
const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle;
|
||||
const ContentDescription = isDesktop
|
||||
? DialogDescription
|
||||
@ -297,9 +298,9 @@ export function GroupedClassificationCard({
|
||||
<Trigger asChild></Trigger>
|
||||
<Content
|
||||
className={cn(
|
||||
"",
|
||||
"scrollbar-container",
|
||||
isDesktop && "min-w-[50%] max-w-[65%]",
|
||||
isMobile && "flex flex-col",
|
||||
isMobile && "overflow-y-auto",
|
||||
)}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
@ -307,28 +308,45 @@ export function GroupedClassificationCard({
|
||||
<Header
|
||||
className={cn(
|
||||
"mx-2 flex flex-row items-center gap-4",
|
||||
isMobile && "flex-shrink-0",
|
||||
isMobileOnly && "top-0 mx-4",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<ContentTitle
|
||||
className={cn(
|
||||
"flex items-center gap-2 font-normal capitalize",
|
||||
isMobile && "px-2",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"",
|
||||
isMobile && "flex flex-col items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<ContentTitle className="flex items-center gap-2 font-normal capitalize">
|
||||
{event?.sub_label && event.sub_label !== "none"
|
||||
? event.sub_label
|
||||
: t(noClassificationLabel)}
|
||||
{event?.sub_label && event.sub_label !== "none" && (
|
||||
<div
|
||||
className={cn(
|
||||
"",
|
||||
bestScoreStatus == "match" && "text-success",
|
||||
bestScoreStatus == "potential" && "text-orange-400",
|
||||
bestScoreStatus == "unknown" && "text-danger",
|
||||
)}
|
||||
>{`${Math.round((event.data.sub_label_score || 0) * 100)}%`}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"",
|
||||
bestScoreStatus == "match" && "text-success",
|
||||
bestScoreStatus == "potential" && "text-orange-400",
|
||||
bestScoreStatus == "unknown" && "text-danger",
|
||||
)}
|
||||
>{`${Math.round((event.data.sub_label_score || 0) * 100)}%`}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="focus:outline-none"
|
||||
aria-label={t("details.scoreInfo", {
|
||||
ns: i18nLibrary,
|
||||
})}
|
||||
>
|
||||
<LuInfo className="size-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 text-sm">
|
||||
{t("details.scoreInfo", { ns: i18nLibrary })}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</ContentTitle>
|
||||
<ContentDescription className={cn("", isMobile && "px-2")}>
|
||||
@ -372,7 +390,7 @@ export function GroupedClassificationCard({
|
||||
className={cn(
|
||||
"grid w-full auto-rows-min grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6 2xl:grid-cols-8",
|
||||
isDesktop && "p-2",
|
||||
isMobile && "scrollbar-container flex-1 overflow-y-auto",
|
||||
isMobile && "px-4 pb-4",
|
||||
)}
|
||||
>
|
||||
{group.map((data: ClassificationItemData) => (
|
||||
|
||||
@ -37,6 +37,8 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LuCircle } from "react-icons/lu";
|
||||
import { MdAutoAwesome } from "react-icons/md";
|
||||
|
||||
type ReviewCardProps = {
|
||||
event: ReviewSegment;
|
||||
@ -142,7 +144,7 @@ export default function ReviewCard({
|
||||
className={cn(
|
||||
"size-full rounded-lg",
|
||||
activeReviewItem?.id == event.id &&
|
||||
"outline outline-[3px] outline-offset-1 outline-selected",
|
||||
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200",
|
||||
imgLoaded ? "visible" : "invisible",
|
||||
)}
|
||||
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
|
||||
@ -163,21 +165,33 @@ export default function ReviewCard({
|
||||
<div className="flex items-center justify-between">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-evenly gap-1">
|
||||
<>
|
||||
{event.data.objects.map((object) => {
|
||||
return getIconForLabel(
|
||||
object,
|
||||
"size-3 text-primary dark:text-white",
|
||||
);
|
||||
})}
|
||||
{event.data.audio.map((audio) => {
|
||||
return getIconForLabel(
|
||||
audio,
|
||||
"size-3 text-primary dark:text-white",
|
||||
);
|
||||
})}
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<LuCircle
|
||||
className={cn(
|
||||
"size-2",
|
||||
event.severity == "alert"
|
||||
? "fill-severity_alert text-severity_alert"
|
||||
: "fill-severity_detection text-severity_detection",
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
{event.data.objects.map((object, idx) => (
|
||||
<div
|
||||
key={`${object}-${idx}`}
|
||||
className="rounded-full bg-muted-foreground p-1"
|
||||
>
|
||||
{getIconForLabel(object, "size-3 text-white")}
|
||||
</div>
|
||||
))}
|
||||
{event.data.audio.map((audio, idx) => (
|
||||
<div
|
||||
key={`${audio}-${idx}`}
|
||||
className="rounded-full bg-muted-foreground p-1"
|
||||
>
|
||||
{getIconForLabel(audio, "size-3 text-white")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="font-extra-light text-xs">{formattedDate}</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
@ -204,6 +218,14 @@ export default function ReviewCard({
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
{event.data.metadata?.title && (
|
||||
<div className="flex items-center gap-1.5 rounded bg-secondary/50">
|
||||
<MdAutoAwesome className="size-3 shrink-0 text-primary" />
|
||||
<span className="truncate text-xs text-primary">
|
||||
{event.data.metadata.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@ -0,0 +1,477 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
CustomClassificationModelConfig,
|
||||
FrigateConfig,
|
||||
} from "@/types/frigateConfig";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import axios from "axios";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuPlus, LuX } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { z } from "zod";
|
||||
|
||||
type ClassificationModelEditDialogProps = {
|
||||
open: boolean;
|
||||
model: CustomClassificationModelConfig;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
type ObjectClassificationType = "sub_label" | "attribute";
|
||||
|
||||
type ObjectFormData = {
|
||||
objectLabel: string;
|
||||
objectType: ObjectClassificationType;
|
||||
};
|
||||
|
||||
type StateFormData = {
|
||||
classes: string[];
|
||||
};
|
||||
|
||||
export default function ClassificationModelEditDialog({
|
||||
open,
|
||||
model,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ClassificationModelEditDialogProps) {
|
||||
const { t } = useTranslation(["views/classificationModel"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const isStateModel = model.state_config !== undefined;
|
||||
const isObjectModel = model.object_config !== undefined;
|
||||
|
||||
const objectLabels = useMemo(() => {
|
||||
if (!config) return [];
|
||||
|
||||
const labels = new Set<string>();
|
||||
|
||||
Object.values(config.cameras).forEach((cameraConfig) => {
|
||||
if (!cameraConfig.enabled || !cameraConfig.enabled_in_config) {
|
||||
return;
|
||||
}
|
||||
|
||||
cameraConfig.objects.track.forEach((label) => {
|
||||
if (!config.model.all_attributes.includes(label)) {
|
||||
labels.add(label);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return [...labels].sort();
|
||||
}, [config]);
|
||||
|
||||
// Define form schema based on model type
|
||||
const formSchema = useMemo(() => {
|
||||
if (isObjectModel) {
|
||||
return z.object({
|
||||
objectLabel: z
|
||||
.string()
|
||||
.min(1, t("wizard.step1.errors.objectLabelRequired")),
|
||||
objectType: z.enum(["sub_label", "attribute"]),
|
||||
});
|
||||
} else {
|
||||
// State model
|
||||
return z.object({
|
||||
classes: z
|
||||
.array(z.string())
|
||||
.min(1, t("wizard.step1.errors.classRequired"))
|
||||
.refine(
|
||||
(classes) => {
|
||||
const nonEmpty = classes.filter((c) => c.trim().length > 0);
|
||||
return nonEmpty.length >= 2;
|
||||
},
|
||||
{ message: t("wizard.step1.errors.stateRequiresTwoClasses") },
|
||||
)
|
||||
.refine(
|
||||
(classes) => {
|
||||
const nonEmpty = classes.filter((c) => c.trim().length > 0);
|
||||
const unique = new Set(nonEmpty.map((c) => c.toLowerCase()));
|
||||
return unique.size === nonEmpty.length;
|
||||
},
|
||||
{ message: t("wizard.step1.errors.classesUnique") },
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [isObjectModel, t]);
|
||||
|
||||
const form = useForm<ObjectFormData | StateFormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: isObjectModel
|
||||
? ({
|
||||
objectLabel: model.object_config?.objects?.[0] || "",
|
||||
objectType:
|
||||
(model.object_config
|
||||
?.classification_type as ObjectClassificationType) || "sub_label",
|
||||
} as ObjectFormData)
|
||||
: ({
|
||||
classes: [""], // Will be populated from dataset
|
||||
} as StateFormData),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
// Fetch dataset to get current classes for state models
|
||||
const { data: dataset } = useSWR<{
|
||||
[id: string]: string[];
|
||||
}>(isStateModel ? `classification/${model.name}/dataset` : null, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
// Update form with classes from dataset when loaded
|
||||
useEffect(() => {
|
||||
if (isStateModel && dataset) {
|
||||
const classes = Object.keys(dataset).filter((key) => key !== "none");
|
||||
if (classes.length > 0) {
|
||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||
"classes",
|
||||
classes,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [dataset, isStateModel, form]);
|
||||
|
||||
const watchedClasses = isStateModel
|
||||
? (form as ReturnType<typeof useForm<StateFormData>>).watch("classes")
|
||||
: undefined;
|
||||
const watchedObjectType = isObjectModel
|
||||
? (form as ReturnType<typeof useForm<ObjectFormData>>).watch("objectType")
|
||||
: undefined;
|
||||
|
||||
const handleAddClass = useCallback(() => {
|
||||
const currentClasses = (
|
||||
form as ReturnType<typeof useForm<StateFormData>>
|
||||
).getValues("classes");
|
||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||
"classes",
|
||||
[...currentClasses, ""],
|
||||
{
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}, [form]);
|
||||
|
||||
const handleRemoveClass = useCallback(
|
||||
(index: number) => {
|
||||
const currentClasses = (
|
||||
form as ReturnType<typeof useForm<StateFormData>>
|
||||
).getValues("classes");
|
||||
const newClasses = currentClasses.filter((_, i) => i !== index);
|
||||
|
||||
// Ensure at least one field remains (even if empty)
|
||||
if (newClasses.length === 0) {
|
||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||
"classes",
|
||||
[""],
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
} else {
|
||||
(form as ReturnType<typeof useForm<StateFormData>>).setValue(
|
||||
"classes",
|
||||
newClasses,
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (data: ObjectFormData | StateFormData) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (isObjectModel) {
|
||||
const objectData = data as ObjectFormData;
|
||||
|
||||
// Update the config
|
||||
await axios.put("/config/set", {
|
||||
requires_restart: 0,
|
||||
update_topic: `config/classification/custom/${model.name}`,
|
||||
config_data: {
|
||||
classification: {
|
||||
custom: {
|
||||
[model.name]: {
|
||||
enabled: model.enabled,
|
||||
name: model.name,
|
||||
threshold: model.threshold,
|
||||
object_config: {
|
||||
objects: [objectData.objectLabel],
|
||||
classification_type: objectData.objectType,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(t("toast.success.updatedModel"), {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
// State model - update classes
|
||||
// Note: For state models, updating classes requires renaming categories
|
||||
// which is handled through the dataset API, not the config API
|
||||
// We'll need to implement this by calling the rename endpoint for each class
|
||||
// For now, we just show a message that this requires retraining
|
||||
|
||||
toast.info(t("edit.stateClassesInfo"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const error = err as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
};
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("toast.error.updateModelFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[isObjectModel, model, t, onSuccess, onClose],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
form.reset();
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("edit.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isStateModel
|
||||
? t("edit.descriptionState")
|
||||
: t("edit.descriptionObject")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{isObjectModel && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="objectLabel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("wizard.step1.objectLabel")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"wizard.step1.objectLabelPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{objectLabels.map((label) => (
|
||||
<SelectItem
|
||||
key={label}
|
||||
value={label}
|
||||
className="cursor-pointer hover:bg-secondary-highlight"
|
||||
>
|
||||
{getTranslatedLabel(label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="objectType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("wizard.step1.classificationType")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex flex-col gap-4 pt-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
className={
|
||||
watchedObjectType === "sub_label"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
id="sub_label"
|
||||
value="sub_label"
|
||||
/>
|
||||
<Label
|
||||
className="cursor-pointer"
|
||||
htmlFor="sub_label"
|
||||
>
|
||||
{t("wizard.step1.classificationSubLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
className={
|
||||
watchedObjectType === "attribute"
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
id="attribute"
|
||||
value="attribute"
|
||||
/>
|
||||
<Label
|
||||
className="cursor-pointer"
|
||||
htmlFor="attribute"
|
||||
>
|
||||
{t("wizard.step1.classificationAttribute")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStateModel && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("wizard.step1.states")}
|
||||
</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
|
||||
onClick={handleAddClass}
|
||||
>
|
||||
<LuPlus />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{watchedClasses?.map((_: string, index: number) => (
|
||||
<FormField
|
||||
key={index}
|
||||
control={
|
||||
(form as ReturnType<typeof useForm<StateFormData>>)
|
||||
.control
|
||||
}
|
||||
name={`classes.${index}` as const}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="text-md h-8"
|
||||
placeholder={t(
|
||||
"wizard.step1.classPlaceholder",
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
{watchedClasses &&
|
||||
watchedClasses.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleRemoveClass(index)}
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isStateModel &&
|
||||
"classes" in form.formState.errors &&
|
||||
form.formState.errors.classes && (
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{form.formState.errors.classes.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="sm:flex-1"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="select"
|
||||
className="flex items-center justify-center gap-2 sm:flex-1"
|
||||
disabled={!form.formState.isValid || isSaving}
|
||||
>
|
||||
{isSaving
|
||||
? t("button.saving", { ns: "common" })
|
||||
: t("button.save", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { generateFixedHash, isValidId } from "@/utils/stringUtil";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -25,6 +25,7 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
|
||||
processId?: (name: string) => string;
|
||||
placeholderName?: string;
|
||||
placeholderId?: string;
|
||||
idVisible?: boolean;
|
||||
};
|
||||
|
||||
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
@ -39,10 +40,12 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
processId,
|
||||
placeholderName,
|
||||
placeholderId,
|
||||
idVisible,
|
||||
}: NameAndIdFieldsProps<T>) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { watch, setValue, trigger } = useFormContext<T>();
|
||||
const [isIdVisible, setIsIdVisible] = useState(false);
|
||||
const { watch, setValue, trigger, formState } = useFormContext<T>();
|
||||
const [isIdVisible, setIsIdVisible] = useState(idVisible ?? false);
|
||||
const hasUserTypedRef = useRef(false);
|
||||
|
||||
const defaultProcessId = (name: string) => {
|
||||
const normalized = name.replace(/\s+/g, "_").toLowerCase();
|
||||
@ -58,6 +61,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
useEffect(() => {
|
||||
const subscription = watch((value, { name }) => {
|
||||
if (name === nameField) {
|
||||
hasUserTypedRef.current = true;
|
||||
const processedId = effectiveProcessId(value[nameField] || "");
|
||||
setValue(idField, processedId as PathValue<T, Path<T>>);
|
||||
trigger(idField);
|
||||
@ -66,6 +70,14 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]);
|
||||
|
||||
// Auto-expand if there's an error on the ID field after user has typed
|
||||
useEffect(() => {
|
||||
const idError = formState.errors[idField];
|
||||
if (idError && hasUserTypedRef.current && !isIdVisible) {
|
||||
setIsIdVisible(true);
|
||||
}
|
||||
}, [formState.errors, idField, isIdVisible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -258,6 +258,7 @@ export default function CreateTriggerDialog({
|
||||
nameLabel={t("triggers.dialog.form.name.title")}
|
||||
nameDescription={t("triggers.dialog.form.name.description")}
|
||||
placeholderName={t("triggers.dialog.form.name.placeholder")}
|
||||
idVisible={!!trigger}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
|
||||
@ -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(
|
||||
() => ({
|
||||
|
||||
@ -77,7 +77,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import FaceSelectionDialog from "../FaceSelectionDialog";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { CgTranscript } from "react-icons/cg";
|
||||
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { PiPath } from "react-icons/pi";
|
||||
import Heading from "@/components/ui/heading";
|
||||
|
||||
|
||||
@ -32,6 +32,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";
|
||||
@ -86,6 +87,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.
|
||||
@ -822,7 +829,8 @@ function LifecycleIconRow({
|
||||
}}
|
||||
/>
|
||||
<span className="smart-capitalize">
|
||||
{zone.replaceAll("_", " ")}
|
||||
{item.data?.zones_friendly_names?.[zidx] ??
|
||||
zone.replaceAll("_", " ")}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@ -429,7 +429,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) {
|
||||
|
||||
@ -289,6 +289,7 @@ export default function VideoControls({
|
||||
}}
|
||||
onUploadFrame={onUploadFrame}
|
||||
containerRef={containerRef}
|
||||
fullscreen={fullscreen}
|
||||
/>
|
||||
)}
|
||||
{features.fullscreen && toggleFullscreen && (
|
||||
@ -306,6 +307,7 @@ type FrigatePlusUploadButtonProps = {
|
||||
onClose: () => void;
|
||||
onUploadFrame: () => void;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
fullscreen?: boolean;
|
||||
};
|
||||
function FrigatePlusUploadButton({
|
||||
video,
|
||||
@ -313,6 +315,7 @@ function FrigatePlusUploadButton({
|
||||
onClose,
|
||||
onUploadFrame,
|
||||
containerRef,
|
||||
fullscreen,
|
||||
}: FrigatePlusUploadButtonProps) {
|
||||
const { t } = useTranslation(["components/player"]);
|
||||
|
||||
@ -349,7 +352,11 @@ function FrigatePlusUploadButton({
|
||||
/>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
portalProps={{ container: containerRef?.current }}
|
||||
portalProps={
|
||||
fullscreen && containerRef?.current
|
||||
? { container: containerRef.current }
|
||||
: undefined
|
||||
}
|
||||
className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl"
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@ -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,16 @@ 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"
|
||||
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}
|
||||
|
||||
@ -385,7 +385,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.cameraNamePlaceholder",
|
||||
)}
|
||||
@ -475,7 +475,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder="192.168.1.100"
|
||||
{...field}
|
||||
/>
|
||||
@ -495,7 +495,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.usernamePlaceholder",
|
||||
)}
|
||||
@ -518,7 +518,7 @@ export default function Step1NameCamera({
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="h-8 pr-10"
|
||||
className="text-md h-8 pr-10"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"cameraWizard.step1.passwordPlaceholder",
|
||||
@ -558,7 +558,7 @@ export default function Step1NameCamera({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="h-8"
|
||||
className="text-md h-8"
|
||||
placeholder="rtsp://username:password@host:port/path"
|
||||
{...field}
|
||||
/>
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
LuChevronRight,
|
||||
LuSettings,
|
||||
} from "react-icons/lu";
|
||||
import { MdAutoAwesome } from "react-icons/md";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import EventMenu from "@/components/timeline/EventMenu";
|
||||
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
|
||||
@ -367,7 +368,11 @@ function ReviewGroup({
|
||||
return (
|
||||
<div
|
||||
data-review-id={id}
|
||||
className="cursor-pointer rounded-lg bg-secondary py-3"
|
||||
className={`mx-1 cursor-pointer rounded-lg bg-secondary px-0 py-3 outline outline-[2px] -outline-offset-[1.8px] ${
|
||||
isActive
|
||||
? "shadow-selected outline-selected"
|
||||
: "outline-transparent duration-500"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@ -382,10 +387,10 @@ function ReviewGroup({
|
||||
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
|
||||
<LuCircle
|
||||
className={cn(
|
||||
"size-3",
|
||||
isActive
|
||||
? "fill-selected text-selected"
|
||||
: "fill-muted duration-500 dark:fill-secondary-highlight dark:text-secondary-highlight",
|
||||
"size-3 duration-500",
|
||||
review.severity == "alert"
|
||||
? "fill-severity_alert text-severity_alert"
|
||||
: "fill-severity_detection text-severity_detection",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@ -406,8 +411,9 @@ function ReviewGroup({
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{review.data.metadata?.title && (
|
||||
<div className="mb-1 text-sm text-primary-variant">
|
||||
{review.data.metadata.title}
|
||||
<div className="mb-1 flex items-center gap-1 text-sm text-primary-variant">
|
||||
<MdAutoAwesome className="size-3 shrink-0" />
|
||||
<span className="truncate">{review.data.metadata.title}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-1.5">
|
||||
@ -454,6 +460,7 @@ function ReviewGroup({
|
||||
<EventList
|
||||
key={event.id}
|
||||
event={event}
|
||||
review={review}
|
||||
effectiveTime={effectiveTime}
|
||||
annotationOffset={annotationOffset}
|
||||
onSeek={onSeek}
|
||||
@ -488,6 +495,7 @@ function ReviewGroup({
|
||||
|
||||
type EventListProps = {
|
||||
event: Event;
|
||||
review: ReviewSegment;
|
||||
effectiveTime?: number;
|
||||
annotationOffset: number;
|
||||
onSeek: (ts: number, play?: boolean) => void;
|
||||
@ -495,6 +503,7 @@ type EventListProps = {
|
||||
};
|
||||
function EventList({
|
||||
event,
|
||||
review,
|
||||
effectiveTime,
|
||||
annotationOffset,
|
||||
onSeek,
|
||||
@ -613,6 +622,7 @@ function EventList({
|
||||
|
||||
<div className="mt-2">
|
||||
<ObjectTimeline
|
||||
review={review}
|
||||
eventId={event.id}
|
||||
onSeek={handleTimelineClick}
|
||||
effectiveTime={effectiveTime}
|
||||
@ -761,6 +771,7 @@ function LifecycleItem({
|
||||
|
||||
// Fetch and render timeline entries for a single event id on demand.
|
||||
function ObjectTimeline({
|
||||
review,
|
||||
eventId,
|
||||
onSeek,
|
||||
effectiveTime,
|
||||
@ -768,6 +779,7 @@ function ObjectTimeline({
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
review: ReviewSegment;
|
||||
eventId: string;
|
||||
onSeek: (ts: number, play?: boolean) => void;
|
||||
effectiveTime?: number;
|
||||
@ -776,13 +788,27 @@ function ObjectTimeline({
|
||||
endTime?: number;
|
||||
}) {
|
||||
const { t } = useTranslation("views/events");
|
||||
const { data: timeline, isValidating } = useSWR<TrackingDetailsSequence[]>([
|
||||
const { data: fullTimeline, isValidating } = useSWR<
|
||||
TrackingDetailsSequence[]
|
||||
>([
|
||||
"timeline",
|
||||
{
|
||||
source_id: eventId,
|
||||
},
|
||||
]);
|
||||
|
||||
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]);
|
||||
|
||||
if (isValidating && (!timeline || timeline.length === 0)) {
|
||||
return <ActivityIndicator className="ml-2 size-3" />;
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { useApiHost } from "@/api";
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
@ -18,6 +17,7 @@ import { HoverCardPortal } from "@radix-ui/react-hover-card";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
|
||||
import useTapUtils from "@/hooks/use-tap-utils";
|
||||
import ReviewCard from "../card/ReviewCard";
|
||||
|
||||
type EventSegmentProps = {
|
||||
events: ReviewSegment[];
|
||||
@ -54,7 +54,7 @@ export function EventSegment({
|
||||
displaySeverityType,
|
||||
shouldShowRoundedCorners,
|
||||
getEventStart,
|
||||
getEventThumbnail,
|
||||
getEvent,
|
||||
} = useEventSegmentUtils(segmentDuration, events, severityType);
|
||||
|
||||
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
|
||||
@ -87,13 +87,11 @@ export function EventSegment({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [getEventStart, segmentTime]);
|
||||
|
||||
const apiHost = useApiHost();
|
||||
|
||||
const { handleTouchStart } = useTapUtils();
|
||||
|
||||
const eventThumbnail = useMemo(() => {
|
||||
return getEventThumbnail(segmentTime);
|
||||
}, [getEventThumbnail, segmentTime]);
|
||||
const segmentEvent = useMemo(() => {
|
||||
return getEvent(segmentTime);
|
||||
}, [getEvent, segmentTime]);
|
||||
|
||||
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
||||
const segmentKey = useMemo(
|
||||
@ -252,10 +250,7 @@ export function EventSegment({
|
||||
className="w-[250px] rounded-lg p-2 md:rounded-2xl"
|
||||
side="left"
|
||||
>
|
||||
<img
|
||||
className="rounded-lg"
|
||||
src={`${apiHost}${eventThumbnail.replace("/media/frigate/", "")}`}
|
||||
/>
|
||||
{segmentEvent && <ReviewCard event={segmentEvent} />}
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</HoverCard>
|
||||
|
||||
@ -101,7 +101,7 @@ export default function Step1NameAndType({
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
enabled: true,
|
||||
name: initialData?.name ?? trigger?.name ?? "",
|
||||
|
||||
@ -191,8 +191,8 @@ export const useEventSegmentUtils = (
|
||||
[events, getSegmentStart, getSegmentEnd, severityType],
|
||||
);
|
||||
|
||||
const getEventThumbnail = useCallback(
|
||||
(time: number): string => {
|
||||
const getEvent = useCallback(
|
||||
(time: number): ReviewSegment | undefined => {
|
||||
const matchingEvent = events.find((event) => {
|
||||
return (
|
||||
time >= getSegmentStart(event.start_time) &&
|
||||
@ -201,7 +201,7 @@ export const useEventSegmentUtils = (
|
||||
);
|
||||
});
|
||||
|
||||
return matchingEvent?.thumb_path ?? "";
|
||||
return matchingEvent;
|
||||
},
|
||||
[events, getSegmentStart, getSegmentEnd, severityType],
|
||||
);
|
||||
@ -214,6 +214,6 @@ export const useEventSegmentUtils = (
|
||||
getReviewed,
|
||||
shouldShowRoundedCorners,
|
||||
getEventStart,
|
||||
getEventThumbnail,
|
||||
getEvent,
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
@ -157,9 +157,11 @@ function MobileMenuItem({
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn("w-full justify-between pr-2", className)}
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-10 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md px-4 py-2 pr-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect(item.key);
|
||||
onClose?.();
|
||||
@ -167,7 +169,7 @@ function MobileMenuItem({
|
||||
>
|
||||
<div className="smart-capitalize">{t("menu." + item.key)}</div>
|
||||
<LuChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -273,6 +275,9 @@ export default function Settings() {
|
||||
} else {
|
||||
setPageToggle(page as SettingsType);
|
||||
}
|
||||
if (isMobile) {
|
||||
setContentMobileOpen(true);
|
||||
}
|
||||
}
|
||||
// don't clear url params if we're creating a new object mask
|
||||
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
||||
@ -282,6 +287,9 @@ export default function Settings() {
|
||||
const cameraNames = cameras.map((c) => c.name);
|
||||
if (cameraNames.includes(camera)) {
|
||||
setSelectedCamera(camera);
|
||||
if (isMobile) {
|
||||
setContentMobileOpen(true);
|
||||
}
|
||||
}
|
||||
// don't clear url params if we're creating a new object mask or trigger
|
||||
return !(searchParams.has("object_mask") || searchParams.has("event_id"));
|
||||
@ -642,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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@ -306,6 +307,7 @@ export type CustomClassificationModelConfig = {
|
||||
threshold: number;
|
||||
object_config?: {
|
||||
objects: string[];
|
||||
classification_type: string;
|
||||
};
|
||||
state_config?: {
|
||||
cameras: {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -42,7 +42,9 @@ export function getLifecycleItemDescription(
|
||||
return t("trackingDetails.lifecycleItemDesc.entered_zone", {
|
||||
ns: "views/explore",
|
||||
label,
|
||||
zones: formatZonesList(lifecycleItem.data.zones),
|
||||
zones: formatZonesList(
|
||||
lifecycleItem.data.zones_friendly_names ?? lifecycleItem.data.zones,
|
||||
),
|
||||
});
|
||||
case "active":
|
||||
return t("trackingDetails.lifecycleItemDesc.active", {
|
||||
|
||||
@ -43,5 +43,5 @@ export function generateFixedHash(name: string, prefix: string = "id"): string {
|
||||
* @returns True if the name is valid, false otherwise
|
||||
*/
|
||||
export function isValidId(name: string): boolean {
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name) && !/^\d+$/.test(name);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
|
||||
import ClassificationModelEditDialog from "@/components/classification/ClassificationModelEditDialog";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
@ -14,7 +15,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFolderPlus } from "react-icons/fa";
|
||||
import { MdModelTraining } from "react-icons/md";
|
||||
import { LuTrash2 } from "react-icons/lu";
|
||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import useSWR from "swr";
|
||||
import Heading from "@/components/ui/heading";
|
||||
@ -163,6 +164,7 @@ export default function ModelSelectionView({
|
||||
key={config.name}
|
||||
config={config}
|
||||
onClick={() => onClick(config)}
|
||||
onUpdate={() => refreshConfig()}
|
||||
onDelete={() => refreshConfig()}
|
||||
/>
|
||||
))}
|
||||
@ -201,9 +203,10 @@ function NoModelsView({
|
||||
type ModelCardProps = {
|
||||
config: CustomClassificationModelConfig;
|
||||
onClick: () => void;
|
||||
onUpdate: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
||||
function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||
const { t } = useTranslation(["views/classificationModel"]);
|
||||
|
||||
const { data: dataset } = useSWR<{
|
||||
@ -211,6 +214,7 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
||||
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
try {
|
||||
@ -250,6 +254,11 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const coverImage = useMemo(() => {
|
||||
if (!dataset) {
|
||||
return undefined;
|
||||
@ -270,6 +279,13 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClassificationModelEditDialog
|
||||
open={editDialogOpen}
|
||||
model={config}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
onSuccess={() => onUpdate()}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||
@ -320,6 +336,10 @@ function ModelCard({ config, onClick, onDelete }: ModelCardProps) {
|
||||
align="end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem onClick={handleEditClick}>
|
||||
<LuPencil className="mr-2 size-4" />
|
||||
<span>{t("button.edit", { ns: "common" })}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDeleteClick}>
|
||||
<LuTrash2 className="mr-2 size-4" />
|
||||
<span>{t("button.delete", { ns: "common" })}</span>
|
||||
|
||||
@ -327,31 +327,39 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
|
||||
</AlertDialog>
|
||||
|
||||
<div className="flex flex-row justify-between gap-2 p-2 align-middle">
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<LibrarySelector
|
||||
pageToggle={pageToggle}
|
||||
dataset={dataset || {}}
|
||||
trainImages={trainImages || []}
|
||||
setPageToggle={setPageToggle}
|
||||
onDelete={onDelete}
|
||||
onRename={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{(isDesktop || !selectedImages?.length) && (
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
aria-label={t("label.back", { ns: "common" })}
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("button.back", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<LibrarySelector
|
||||
pageToggle={pageToggle}
|
||||
dataset={dataset || {}}
|
||||
trainImages={trainImages || []}
|
||||
setPageToggle={setPageToggle}
|
||||
onDelete={onDelete}
|
||||
onRename={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedImages?.length > 0 ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="mx-1 flex w-48 items-center justify-center text-sm text-muted-foreground">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-end gap-2",
|
||||
isMobileOnly && "justify-between",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-48 items-center justify-center text-sm text-muted-foreground">
|
||||
<div className="p-1">{`${selectedImages.length} selected`}</div>
|
||||
<div className="p-1">{"|"}</div>
|
||||
<div
|
||||
|
||||
@ -136,7 +136,7 @@ export default function EventView({
|
||||
|
||||
const [selectedReviews, setSelectedReviews] = useState<ReviewSegment[]>([]);
|
||||
const onSelectReview = useCallback(
|
||||
(review: ReviewSegment, ctrl: boolean) => {
|
||||
(review: ReviewSegment, ctrl: boolean, detail: boolean) => {
|
||||
if (selectedReviews.length > 0 || ctrl) {
|
||||
const index = selectedReviews.findIndex((r) => r.id === review.id);
|
||||
|
||||
@ -156,17 +156,31 @@ export default function EventView({
|
||||
setSelectedReviews(copy);
|
||||
}
|
||||
} else {
|
||||
// If a specific date is selected in the calendar and it's after the event start,
|
||||
// use the selected date instead of the event start time
|
||||
const effectiveStartTime =
|
||||
timeRange.after > review.start_time
|
||||
? timeRange.after
|
||||
: review.start_time;
|
||||
|
||||
onOpenRecording({
|
||||
camera: review.camera,
|
||||
startTime: review.start_time - REVIEW_PADDING,
|
||||
startTime: effectiveStartTime - REVIEW_PADDING,
|
||||
severity: review.severity,
|
||||
timelineType: detail ? "detail" : undefined,
|
||||
});
|
||||
|
||||
review.has_been_reviewed = true;
|
||||
markItemAsReviewed(review);
|
||||
}
|
||||
},
|
||||
[selectedReviews, setSelectedReviews, onOpenRecording, markItemAsReviewed],
|
||||
[
|
||||
selectedReviews,
|
||||
setSelectedReviews,
|
||||
onOpenRecording,
|
||||
markItemAsReviewed,
|
||||
timeRange.after,
|
||||
],
|
||||
);
|
||||
const onSelectAllReviews = useCallback(() => {
|
||||
if (!currentReviewItems || currentReviewItems.length == 0) {
|
||||
@ -402,7 +416,6 @@ export default function EventView({
|
||||
onSelectAllReviews={onSelectAllReviews}
|
||||
setSelectedReviews={setSelectedReviews}
|
||||
pullLatestData={pullLatestData}
|
||||
onOpenRecording={onOpenRecording}
|
||||
/>
|
||||
)}
|
||||
{severity == "significant_motion" && (
|
||||
@ -442,11 +455,14 @@ type DetectionReviewProps = {
|
||||
loading: boolean;
|
||||
markItemAsReviewed: (review: ReviewSegment) => void;
|
||||
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
||||
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
|
||||
onSelectReview: (
|
||||
review: ReviewSegment,
|
||||
ctrl: boolean,
|
||||
detail: boolean,
|
||||
) => void;
|
||||
onSelectAllReviews: () => void;
|
||||
setSelectedReviews: (reviews: ReviewSegment[]) => void;
|
||||
pullLatestData: () => void;
|
||||
onOpenRecording: (recordingInfo: RecordingStartingPoint) => void;
|
||||
};
|
||||
function DetectionReview({
|
||||
contentRef,
|
||||
@ -466,7 +482,6 @@ function DetectionReview({
|
||||
onSelectAllReviews,
|
||||
setSelectedReviews,
|
||||
pullLatestData,
|
||||
onOpenRecording,
|
||||
}: DetectionReviewProps) {
|
||||
const { t } = useTranslation(["views/events"]);
|
||||
|
||||
@ -758,16 +773,7 @@ function DetectionReview({
|
||||
ctrl: boolean,
|
||||
detail: boolean,
|
||||
) => {
|
||||
if (detail) {
|
||||
onOpenRecording({
|
||||
camera: review.camera,
|
||||
startTime: review.start_time - REVIEW_PADDING,
|
||||
severity: review.severity,
|
||||
timelineType: "detail",
|
||||
});
|
||||
} else {
|
||||
onSelectReview(review, ctrl);
|
||||
}
|
||||
onSelectReview(review, ctrl, detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -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";
|
||||
@ -970,12 +970,11 @@ function Timeline({
|
||||
"relative overflow-hidden",
|
||||
isDesktop
|
||||
? cn(
|
||||
"no-scrollbar overflow-y-auto",
|
||||
timelineType == "timeline"
|
||||
? "w-[100px] flex-shrink-0"
|
||||
: timelineType == "detail"
|
||||
? "min-w-[20rem] max-w-[30%] flex-shrink-0 flex-grow-0 basis-[30rem] md:min-w-[20rem] md:max-w-[25%] lg:min-w-[30rem] lg:max-w-[33%]"
|
||||
: "w-60 flex-shrink-0",
|
||||
: "w-80 flex-shrink-0",
|
||||
)
|
||||
: cn(
|
||||
timelineType == "timeline"
|
||||
|
||||
@ -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,7 @@ 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";
|
||||
|
||||
type CameraSettingsViewProps = {
|
||||
selectedCamera: string;
|
||||
@ -86,16 +86,23 @@ 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: getZoneName(name, cameraConfig.name),
|
||||
objects: zoneData.objects,
|
||||
color: zoneData.color,
|
||||
}));
|
||||
}
|
||||
}, [cameraConfig]);
|
||||
}, [cameraConfig, getZoneName]);
|
||||
|
||||
const alertsLabels = useMemo(() => {
|
||||
return cameraConfig?.review.alerts.labels
|
||||
@ -526,7 +533,7 @@ export default function CameraSettingsView({
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal smart-capitalize">
|
||||
{zone.name.replaceAll("_", " ")}
|
||||
{zone.friendly_name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
@ -548,14 +555,9 @@ export default function CameraSettingsView({
|
||||
"cameraReview.reviewClassification.zoneObjectAlertsTips",
|
||||
{
|
||||
alertsLabels,
|
||||
zone: watchedAlertsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
zone: watchedAlertsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
},
|
||||
)
|
||||
@ -628,7 +630,7 @@ export default function CameraSettingsView({
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal smart-capitalize">
|
||||
{zone.name.replaceAll("_", " ")}
|
||||
{zone.friendly_name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
@ -667,14 +669,9 @@ export default function CameraSettingsView({
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: watchedDetectionsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
zone: watchedDetectionsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
@ -684,14 +681,9 @@ export default function CameraSettingsView({
|
||||
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
|
||||
values={{
|
||||
detectionsLabels,
|
||||
zone: watchedDetectionsZones
|
||||
.map((zone) =>
|
||||
capitalizeFirstLetter(zone).replaceAll(
|
||||
"_",
|
||||
" ",
|
||||
),
|
||||
)
|
||||
.join(", "),
|
||||
zone: watchedDetectionsZones.map((zone) =>
|
||||
getZoneName(zone),
|
||||
),
|
||||
cameraName: selectCameraName,
|
||||
}}
|
||||
ns="views/settings"
|
||||
@ -717,11 +709,11 @@ export default function CameraSettingsView({
|
||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
||||
<Button
|
||||
className="flex flex-1"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
aria-label={t("button.reset", { ns: "common" })}
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
<Trans>button.cancel</Trans>
|
||||
<Trans>button.reset</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="select"
|
||||
|
||||
@ -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…
x
Reference in New Issue
Block a user