Compare commits

..

1 Commits

79 changed files with 2094 additions and 2905 deletions

View File

@ -5,12 +5,6 @@ set -euxo pipefail
SQLITE3_VERSION="3.46.1" SQLITE3_VERSION="3.46.1"
PYSQLITE3_VERSION="0.5.3" 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 # Fetch the pre-built sqlite amalgamation instead of building from source
if [[ ! -d "sqlite" ]]; then if [[ ! -d "sqlite" ]]; then
mkdir sqlite mkdir sqlite

View File

@ -2,9 +2,9 @@
set -e set -e
# Download the MxAccl for Frigate github release # Download the MxAccl for Frigate github release
wget https://github.com/memryx/mx_accl_frigate/archive/refs/tags/v2.1.0.zip -O /tmp/mxaccl.zip wget https://github.com/memryx/mx_accl_frigate/archive/refs/heads/main.zip -O /tmp/mxaccl.zip
unzip /tmp/mxaccl.zip -d /tmp unzip /tmp/mxaccl.zip -d /tmp
mv /tmp/mx_accl_frigate-2.1.0 /opt/mx_accl_frigate mv /tmp/mx_accl_frigate-main /opt/mx_accl_frigate
rm /tmp/mxaccl.zip rm /tmp/mxaccl.zip
# Install Python dependencies # Install Python dependencies

View File

@ -56,7 +56,7 @@ pywebpush == 2.0.*
# alpr # alpr
pyclipper == 1.3.* pyclipper == 1.3.*
shapely == 2.0.* shapely == 2.0.*
rapidfuzz==3.12.* Levenshtein==0.26.*
# HailoRT Wheels # HailoRT Wheels
appdirs==1.4.* appdirs==1.4.*
argcomplete==2.0.* argcomplete==2.0.*

View File

@ -24,13 +24,10 @@ echo "Adding MemryX GPG key and repository..."
wget -qO- https://developer.memryx.com/deb/memryx.asc | sudo tee /etc/apt/trusted.gpg.d/memryx.asc >/dev/null wget -qO- https://developer.memryx.com/deb/memryx.asc | sudo tee /etc/apt/trusted.gpg.d/memryx.asc >/dev/null
echo 'deb https://developer.memryx.com/deb stable main' | sudo tee /etc/apt/sources.list.d/memryx.list >/dev/null echo 'deb https://developer.memryx.com/deb stable main' | sudo tee /etc/apt/sources.list.d/memryx.list >/dev/null
# Update and install specific SDK 2.1 packages # Update and install memx-drivers
echo "Installing MemryX SDK 2.1 packages..." echo "Installing memx-drivers..."
sudo apt update sudo apt update
sudo apt install -y memx-drivers=2.1.* memx-accl=2.1.* mxa-manager=2.1.* sudo apt install -y memx-drivers
# Hold packages to prevent automatic upgrades
sudo apt-mark hold memx-drivers memx-accl mxa-manager
# ARM-specific board setup # ARM-specific board setup
if [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then if [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then
@ -40,5 +37,11 @@ fi
echo -e "\n\n\033[1;31mYOU MUST RESTART YOUR COMPUTER NOW\033[0m\n\n" echo -e "\n\n\033[1;31mYOU MUST RESTART YOUR COMPUTER NOW\033[0m\n\n"
echo "MemryX SDK 2.1 installation complete!" # Install other runtime packages
packages=("memx-accl" "mxa-manager")
for pkg in "${packages[@]}"; do
echo "Installing $pkg..."
sudo apt install -y "$pkg"
done
echo "MemryX installation complete!"

View File

@ -21,7 +21,7 @@ FROM deps AS frigate-tensorrt
ARG PIP_BREAK_SYSTEM_PACKAGES ARG PIP_BREAK_SYSTEM_PACKAGES
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \ RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \
pip3 uninstall -y onnxruntime \ pip3 uninstall -y onnxruntime tensorflow-cpu \
&& pip3 install -U /deps/trt-wheels/*.whl && pip3 install -U /deps/trt-wheels/*.whl
COPY --from=rootfs / / COPY --from=rootfs / /

View File

@ -112,7 +112,7 @@ RUN apt-get update \
&& apt-get install -y protobuf-compiler libprotobuf-dev \ && apt-get install -y protobuf-compiler libprotobuf-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN --mount=type=bind,source=docker/tensorrt/requirements-models-arm64.txt,target=/requirements-tensorrt-models.txt \ RUN --mount=type=bind,source=docker/tensorrt/requirements-models-arm64.txt,target=/requirements-tensorrt-models.txt \
pip3 wheel --wheel-dir=/trt-model-wheels --no-deps -r /requirements-tensorrt-models.txt pip3 wheel --wheel-dir=/trt-model-wheels -r /requirements-tensorrt-models.txt
FROM wget AS jetson-ffmpeg FROM wget AS jetson-ffmpeg
ARG DEBIAN_FRONTEND ARG DEBIAN_FRONTEND
@ -145,8 +145,7 @@ COPY --from=trt-wheels /etc/TENSORRT_VER /etc/TENSORRT_VER
RUN --mount=type=bind,from=trt-wheels,source=/trt-wheels,target=/deps/trt-wheels \ 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 \ --mount=type=bind,from=trt-model-wheels,source=/trt-model-wheels,target=/deps/trt-model-wheels \
pip3 uninstall -y onnxruntime \ pip3 uninstall -y onnxruntime \
&& pip3 install -U /deps/trt-wheels/*.whl \ && pip3 install -U /deps/trt-wheels/*.whl /deps/trt-model-wheels/*.whl \
&& pip3 install -U /deps/trt-model-wheels/*.whl \
&& ldconfig && ldconfig
WORKDIR /opt/frigate/ WORKDIR /opt/frigate/

View File

@ -13,6 +13,7 @@ nvidia_cusolver_cu12==11.6.3.*; platform_machine == 'x86_64'
nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64' nvidia_cusparse_cu12==12.5.1.*; platform_machine == 'x86_64'
nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64' nvidia_nccl_cu12==2.23.4; platform_machine == 'x86_64'
nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64' nvidia_nvjitlink_cu12==12.5.82; platform_machine == 'x86_64'
tensorflow==2.19.*; platform_machine == 'x86_64'
onnx==1.16.*; platform_machine == 'x86_64' onnx==1.16.*; platform_machine == 'x86_64'
onnxruntime-gpu==1.22.*; platform_machine == 'x86_64' onnxruntime-gpu==1.22.*; platform_machine == 'x86_64'
protobuf==3.20.3; platform_machine == 'x86_64' protobuf==3.20.3; platform_machine == 'x86_64'

View File

@ -1,2 +1 @@
cuda-python == 12.6.*; platform_machine == 'aarch64' cuda-python == 12.6.*; platform_machine == 'aarch64'
numpy == 1.26.*; platform_machine == 'aarch64'

View File

@ -10,6 +10,7 @@ Object classification allows you to train a custom MobileNetV2 classification mo
Object classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate. Object classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
Training the model does briefly use a high amount of system resources for about 13 minutes per training run. On lower-power devices, training may take longer. Training the model does briefly use a high amount of system resources for about 13 minutes per training run. On lower-power devices, training may take longer.
When running the `-tensorrt` image, Nvidia GPUs will automatically be used to accelerate training.
## Classes ## Classes

View File

@ -10,6 +10,7 @@ State classification allows you to train a custom MobileNetV2 classification mod
State classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate. State classification models are lightweight and run very fast on CPU. Inference should be usable on virtually any machine that can run Frigate.
Training the model does briefly use a high amount of system resources for about 13 minutes per training run. On lower-power devices, training may take longer. Training the model does briefly use a high amount of system resources for about 13 minutes per training run. On lower-power devices, training may take longer.
When running the `-tensorrt` image, Nvidia GPUs will automatically be used to accelerate training.
## Classes ## Classes

View File

@ -37,6 +37,7 @@ from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.util.builtin import ( from frigate.util.builtin import (
clean_camera_user_pass, clean_camera_user_pass,
flatten_config_data, flatten_config_data,
get_tz_modifiers,
process_config_query_string, process_config_query_string,
update_yaml_file_bulk, update_yaml_file_bulk,
) )
@ -47,7 +48,6 @@ from frigate.util.services import (
restart_frigate, restart_frigate,
vainfo_hwaccel, vainfo_hwaccel,
) )
from frigate.util.time import get_tz_modifiers
from frigate.version import VERSION from frigate.version import VERSION
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -403,10 +403,9 @@ def config_set(request: Request, body: AppConfigSetBody):
settings, settings,
) )
else: else:
# Generic handling for global config updates # Handle nested config updates (e.g., config/classification/custom/{name})
settings = config.get_nested_object(body.update_topic) settings = config.get_nested_object(body.update_topic)
if settings:
# Publish None for removal, actual config for add/update
request.app.config_publisher.publisher.publish( request.app.config_publisher.publisher.publish(
body.update_topic, settings body.update_topic, settings
) )

View File

@ -31,14 +31,14 @@ from frigate.api.defs.response.generic_response import GenericResponse
from frigate.api.defs.tags import Tags from frigate.api.defs.tags import Tags
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera import DetectConfig from frigate.config.camera import DetectConfig
from frigate.const import CLIPS_DIR, FACE_DIR, MODEL_CACHE_DIR from frigate.const import CLIPS_DIR, FACE_DIR
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Event from frigate.models import Event
from frigate.util.classification import ( from frigate.util.classification import (
collect_object_classification_examples, collect_object_classification_examples,
collect_state_classification_examples, collect_state_classification_examples,
) )
from frigate.util.file import get_event_snapshot from frigate.util.path import get_event_snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -804,46 +804,3 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
content={"success": True, "message": "Example generation completed"}, content={"success": True, "message": "Example generation completed"},
status_code=200, status_code=200,
) )
@router.delete(
"/classification/{name}",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Delete a classification model",
description="""Deletes a specific classification model and all its associated data.
The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
)
def delete_classification_model(request: Request, name: str):
config: FrigateConfig = request.app.frigate_config
if name not in config.classification.custom:
return JSONResponse(
content=(
{
"success": False,
"message": f"{name} is not a known classification model.",
}
),
status_code=404,
)
# Delete the classification model's data directory in clips
data_dir = os.path.join(CLIPS_DIR, sanitize_filename(name))
if os.path.exists(data_dir):
shutil.rmtree(data_dir)
# Delete the classification model's files in model_cache
model_dir = os.path.join(MODEL_CACHE_DIR, sanitize_filename(name))
if os.path.exists(model_dir):
shutil.rmtree(model_dir)
return JSONResponse(
content=(
{
"success": True,
"message": f"Successfully deleted classification model {name}.",
}
),
status_code=200,
)

View File

@ -2,7 +2,6 @@
import base64 import base64
import datetime import datetime
import json
import logging import logging
import os import os
import random import random
@ -58,8 +57,8 @@ from frigate.const import CLIPS_DIR, TRIGGER_DIR
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Event, ReviewSegment, Timeline, Trigger from frigate.models import Event, ReviewSegment, Timeline, Trigger
from frigate.track.object_processing import TrackedObject from frigate.track.object_processing import TrackedObject
from frigate.util.file import get_event_thumbnail_bytes from frigate.util.builtin import get_tz_modifiers
from frigate.util.time import get_dst_transitions, get_tz_modifiers from frigate.util.path import get_event_thumbnail_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -814,6 +813,7 @@ def events_summary(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
): ):
tz_name = params.timezone tz_name = params.timezone
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
has_clip = params.has_clip has_clip = params.has_clip
has_snapshot = params.has_snapshot has_snapshot = params.has_snapshot
@ -828,33 +828,7 @@ def events_summary(
if len(clauses) == 0: if len(clauses) == 0:
clauses.append((True)) clauses.append((True))
time_range_query = ( groups = (
Event.select(
fn.MIN(Event.start_time).alias("min_time"),
fn.MAX(Event.start_time).alias("max_time"),
)
.where(reduce(operator.and_, clauses) & (Event.camera << allowed_cameras))
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
if min_time is None or max_time is None:
return JSONResponse(content=[])
dst_periods = get_dst_transitions(tz_name, min_time, max_time)
grouped: dict[tuple, dict] = {}
for period_start, period_end, period_offset in dst_periods:
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
period_groups = (
Event.select( Event.select(
Event.camera, Event.camera,
Event.label, Event.label,
@ -863,56 +837,24 @@ def events_summary(
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
fn.datetime( fn.datetime(
Event.start_time, Event.start_time, "unixepoch", hour_modifier, minute_modifier
"unixepoch",
period_hour_modifier,
period_minute_modifier,
), ),
).alias("day"), ).alias("day"),
Event.zones, Event.zones,
fn.COUNT(Event.id).alias("count"), fn.COUNT(Event.id).alias("count"),
) )
.where( .where(reduce(operator.and_, clauses) & (Event.camera << allowed_cameras))
reduce(operator.and_, clauses)
& (Event.camera << allowed_cameras)
& (Event.start_time >= period_start)
& (Event.start_time <= period_end)
)
.group_by( .group_by(
Event.camera, Event.camera,
Event.label, Event.label,
Event.sub_label, Event.sub_label,
Event.data, Event.data,
(Event.start_time + period_offset).cast("int") / (3600 * 24), (Event.start_time + seconds_offset).cast("int") / (3600 * 24),
Event.zones, Event.zones,
) )
.namedtuples()
) )
for g in period_groups: return JSONResponse(content=[e for e in groups.dicts()])
key = (
g.camera,
g.label,
g.sub_label,
json.dumps(g.data, sort_keys=True) if g.data is not None else None,
g.day,
json.dumps(g.zones, sort_keys=True) if g.zones is not None else None,
)
if key in grouped:
grouped[key]["count"] += int(g.count or 0)
else:
grouped[key] = {
"camera": g.camera,
"label": g.label,
"sub_label": g.sub_label,
"data": g.data,
"day": g.day,
"zones": g.zones,
"count": int(g.count or 0),
}
return JSONResponse(content=list(grouped.values()))
@router.get( @router.get(

View File

@ -34,7 +34,7 @@ from frigate.record.export import (
PlaybackSourceEnum, PlaybackSourceEnum,
RecordingExporter, RecordingExporter,
) )
from frigate.util.time import is_current_hour from frigate.util.builtin import is_current_hour
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -44,9 +44,9 @@ from frigate.const import (
) )
from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.track.object_processing import TrackedObjectProcessor from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.file import get_event_thumbnail_bytes from frigate.util.builtin import get_tz_modifiers
from frigate.util.image import get_image_from_recording from frigate.util.image import get_image_from_recording
from frigate.util.time import get_dst_transitions from frigate.util.path import get_event_thumbnail_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -424,6 +424,7 @@ def all_recordings_summary(
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter), allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
): ):
"""Returns true/false by day indicating if recordings exist""" """Returns true/false by day indicating if recordings exist"""
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
cameras = params.cameras cameras = params.cameras
if cameras != "all": if cameras != "all":
@ -431,70 +432,41 @@ def all_recordings_summary(
filtered = requested.intersection(allowed_cameras) filtered = requested.intersection(allowed_cameras)
if not filtered: if not filtered:
return JSONResponse(content={}) return JSONResponse(content={})
camera_list = list(filtered) cameras = ",".join(filtered)
else: else:
camera_list = allowed_cameras cameras = allowed_cameras
time_range_query = ( query = (
Recordings.select(
fn.MIN(Recordings.start_time).alias("min_time"),
fn.MAX(Recordings.start_time).alias("max_time"),
)
.where(Recordings.camera << camera_list)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
if min_time is None or max_time is None:
return JSONResponse(content={})
dst_periods = get_dst_transitions(params.timezone, min_time, max_time)
days: dict[str, bool] = {}
for period_start, period_end, period_offset in dst_periods:
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
period_query = (
Recordings.select( Recordings.select(
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
fn.datetime( fn.datetime(
Recordings.start_time, Recordings.start_time + seconds_offset,
"unixepoch", "unixepoch",
period_hour_modifier, hour_modifier,
period_minute_modifier, minute_modifier,
), ),
).alias("day") ).alias("day")
) )
.where(
(Recordings.camera << camera_list)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by( .group_by(
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
fn.datetime( fn.datetime(
Recordings.start_time, Recordings.start_time + seconds_offset,
"unixepoch", "unixepoch",
period_hour_modifier, hour_modifier,
period_minute_modifier, minute_modifier,
), ),
) )
) )
.order_by(Recordings.start_time.desc()) .order_by(Recordings.start_time.desc())
.namedtuples()
) )
for g in period_query: if params.cameras != "all":
days[g.day] = True query = query.where(Recordings.camera << cameras.split(","))
recording_days = query.namedtuples()
days = {day.day: True for day in recording_days}
return JSONResponse(content=days) return JSONResponse(content=days)
@ -504,54 +476,21 @@ def all_recordings_summary(
) )
async def recordings_summary(camera_name: str, timezone: str = "utc"): async def recordings_summary(camera_name: str, timezone: str = "utc"):
"""Returns hourly summary for recordings of given camera""" """Returns hourly summary for recordings of given camera"""
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(timezone)
time_range_query = (
Recordings.select(
fn.MIN(Recordings.start_time).alias("min_time"),
fn.MAX(Recordings.start_time).alias("max_time"),
)
.where(Recordings.camera == camera_name)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
days: dict[str, dict] = {}
if min_time is None or max_time is None:
return JSONResponse(content=list(days.values()))
dst_periods = get_dst_transitions(timezone, min_time, max_time)
for period_start, period_end, period_offset in dst_periods:
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
recording_groups = ( recording_groups = (
Recordings.select( Recordings.select(
fn.strftime( fn.strftime(
"%Y-%m-%d %H", "%Y-%m-%d %H",
fn.datetime( fn.datetime(
Recordings.start_time, Recordings.start_time, "unixepoch", hour_modifier, minute_modifier
"unixepoch",
period_hour_modifier,
period_minute_modifier,
), ),
).alias("hour"), ).alias("hour"),
fn.SUM(Recordings.duration).alias("duration"), fn.SUM(Recordings.duration).alias("duration"),
fn.SUM(Recordings.motion).alias("motion"), fn.SUM(Recordings.motion).alias("motion"),
fn.SUM(Recordings.objects).alias("objects"), fn.SUM(Recordings.objects).alias("objects"),
) )
.where( .where(Recordings.camera == camera_name)
(Recordings.camera == camera_name) .group_by((Recordings.start_time + seconds_offset).cast("int") / 3600)
& (Recordings.end_time >= period_start)
& (Recordings.start_time <= period_end)
)
.group_by((Recordings.start_time + period_offset).cast("int") / 3600)
.order_by(Recordings.start_time.desc()) .order_by(Recordings.start_time.desc())
.namedtuples() .namedtuples()
) )
@ -561,24 +500,20 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
fn.strftime( fn.strftime(
"%Y-%m-%d %H", "%Y-%m-%d %H",
fn.datetime( fn.datetime(
Event.start_time, Event.start_time, "unixepoch", hour_modifier, minute_modifier
"unixepoch",
period_hour_modifier,
period_minute_modifier,
), ),
).alias("hour"), ).alias("hour"),
fn.COUNT(Event.id).alias("count"), fn.COUNT(Event.id).alias("count"),
) )
.where(Event.camera == camera_name, Event.has_clip) .where(Event.camera == camera_name, Event.has_clip)
.where( .group_by((Event.start_time + seconds_offset).cast("int") / 3600)
(Event.start_time >= period_start) & (Event.start_time <= period_end)
)
.group_by((Event.start_time + period_offset).cast("int") / 3600)
.namedtuples() .namedtuples()
) )
event_map = {g.hour: g.count for g in event_groups} event_map = {g.hour: g.count for g in event_groups}
days = {}
for recording_group in recording_groups: for recording_group in recording_groups:
parts = recording_group.hour.split() parts = recording_group.hour.split()
hour = parts[1] hour = parts[1]
@ -591,16 +526,11 @@ async def recordings_summary(camera_name: str, timezone: str = "utc"):
"objects": recording_group.objects, "objects": recording_group.objects,
"duration": round(recording_group.duration), "duration": round(recording_group.duration),
} }
if day in days: if day not in days:
# merge counts if already present (edge-case at DST boundary) days[day] = {"events": events_count, "hours": [hour_data], "day": day}
days[day]["events"] += events_count or 0
days[day]["hours"].append(hour_data)
else: else:
days[day] = { days[day]["events"] += events_count
"events": events_count or 0, days[day]["hours"].append(hour_data)
"hours": [hour_data],
"day": day,
}
return JSONResponse(content=list(days.values())) return JSONResponse(content=list(days.values()))

View File

@ -36,7 +36,7 @@ from frigate.config import FrigateConfig
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
from frigate.models import Recordings, ReviewSegment, UserReviewStatus from frigate.models import Recordings, ReviewSegment, UserReviewStatus
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.util.time import get_dst_transitions from frigate.util.builtin import get_tz_modifiers
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -197,6 +197,7 @@ async def review_summary(
user_id = current_user["username"] user_id = current_user["username"]
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(params.timezone)
day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp() day_ago = (datetime.datetime.now() - datetime.timedelta(hours=24)).timestamp()
cameras = params.cameras cameras = params.cameras
@ -328,57 +329,16 @@ async def review_summary(
) )
clauses.append(reduce(operator.or_, label_clauses)) clauses.append(reduce(operator.or_, label_clauses))
# Find the time range of available data
time_range_query = (
ReviewSegment.select(
fn.MIN(ReviewSegment.start_time).alias("min_time"),
fn.MAX(ReviewSegment.start_time).alias("max_time"),
)
.where(reduce(operator.and_, clauses) if clauses else True)
.dicts()
.get()
)
min_time = time_range_query.get("min_time")
max_time = time_range_query.get("max_time")
data = {
"last24Hours": last_24_query,
}
# If no data, return early
if min_time is None or max_time is None:
return JSONResponse(content=data)
# Get DST transition periods
dst_periods = get_dst_transitions(params.timezone, min_time, max_time)
day_in_seconds = 60 * 60 * 24 day_in_seconds = 60 * 60 * 24
last_month_query = (
# Query each DST period separately with the correct offset
for period_start, period_end, period_offset in dst_periods:
# Calculate hour/minute modifiers for this period
hours_offset = int(period_offset / 60 / 60)
minutes_offset = int(period_offset / 60 - hours_offset * 60)
period_hour_modifier = f"{hours_offset} hour"
period_minute_modifier = f"{minutes_offset} minute"
# Build clauses including time range for this period
period_clauses = clauses.copy()
period_clauses.append(
(ReviewSegment.start_time >= period_start)
& (ReviewSegment.start_time <= period_end)
)
period_query = (
ReviewSegment.select( ReviewSegment.select(
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
fn.datetime( fn.datetime(
ReviewSegment.start_time, ReviewSegment.start_time,
"unixepoch", "unixepoch",
period_hour_modifier, hour_modifier,
period_minute_modifier, minute_modifier,
), ),
).alias("day"), ).alias("day"),
fn.SUM( fn.SUM(
@ -439,24 +399,19 @@ async def review_summary(
& (UserReviewStatus.user_id == user_id) & (UserReviewStatus.user_id == user_id)
), ),
) )
.where(reduce(operator.and_, period_clauses)) .where(reduce(operator.and_, clauses) if clauses else True)
.group_by( .group_by(
(ReviewSegment.start_time + period_offset).cast("int") / day_in_seconds (ReviewSegment.start_time + seconds_offset).cast("int") / day_in_seconds
) )
.order_by(ReviewSegment.start_time.desc()) .order_by(ReviewSegment.start_time.desc())
) )
# Merge results from this period data = {
for e in period_query.dicts().iterator(): "last24Hours": last_24_query,
day_key = e["day"] }
if day_key in data:
# Merge counts if day already exists (edge case at DST boundary) for e in last_month_query.dicts().iterator():
data[day_key]["reviewed_alert"] += e["reviewed_alert"] or 0 data[e["day"]] = e
data[day_key]["reviewed_detection"] += e["reviewed_detection"] or 0
data[day_key]["total_alert"] += e["total_alert"] or 0
data[day_key]["total_detection"] += e["total_detection"] or 0
else:
data[day_key] = e
return JSONResponse(content=data) return JSONResponse(content=data)

View File

@ -14,8 +14,8 @@ from typing import Any, List, Optional, Tuple
import cv2 import cv2
import numpy as np import numpy as np
from Levenshtein import distance, jaro_winkler
from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset from pyclipper import ET_CLOSEDPOLYGON, JT_ROUND, PyclipperOffset
from rapidfuzz.distance import JaroWinkler, Levenshtein
from shapely.geometry import Polygon from shapely.geometry import Polygon
from frigate.comms.event_metadata_updater import ( from frigate.comms.event_metadata_updater import (
@ -1123,9 +1123,7 @@ class LicensePlateProcessingMixin:
for i, plate in enumerate(plates): for i, plate in enumerate(plates):
merged = False merged = False
for j, cluster in enumerate(clusters): for j, cluster in enumerate(clusters):
sims = [ sims = [jaro_winkler(plate["plate"], v["plate"]) for v in cluster]
JaroWinkler.similarity(plate["plate"], v["plate"]) for v in cluster
]
if len(sims) > 0: if len(sims) > 0:
avg_sim = sum(sims) / len(sims) avg_sim = sum(sims) / len(sims)
if avg_sim >= self.cluster_threshold: if avg_sim >= self.cluster_threshold:
@ -1502,7 +1500,7 @@ class LicensePlateProcessingMixin:
and current_time - data["last_seen"] and current_time - data["last_seen"]
<= self.config.cameras[camera].lpr.expire_time <= self.config.cameras[camera].lpr.expire_time
): ):
similarity = JaroWinkler.similarity(data["plate"], top_plate) similarity = jaro_winkler(data["plate"], top_plate)
if similarity >= self.similarity_threshold: if similarity >= self.similarity_threshold:
plate_id = existing_id plate_id = existing_id
logger.debug( logger.debug(
@ -1582,8 +1580,7 @@ class LicensePlateProcessingMixin:
for label, plates_list in self.lpr_config.known_plates.items() for label, plates_list in self.lpr_config.known_plates.items()
if any( if any(
re.match(f"^{plate}$", rep_plate) re.match(f"^{plate}$", rep_plate)
or Levenshtein.distance(plate, rep_plate) or distance(plate, rep_plate) <= self.lpr_config.match_distance
<= self.lpr_config.match_distance
for plate in plates_list for plate in plates_list
) )
), ),

View File

@ -20,8 +20,8 @@ from frigate.genai import GenAIClient
from frigate.models import Event from frigate.models import Event
from frigate.types import TrackedObjectUpdateTypesEnum from frigate.types import TrackedObjectUpdateTypesEnum
from frigate.util.builtin import EventsPerSecond, InferenceSpeed 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.image import create_thumbnail, ensure_jpeg_bytes
from frigate.util.path import get_event_thumbnail_bytes
if TYPE_CHECKING: if TYPE_CHECKING:
from frigate.embeddings import Embeddings from frigate.embeddings import Embeddings

View File

@ -22,7 +22,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.embeddings.util import ZScoreNormalization from frigate.embeddings.util import ZScoreNormalization
from frigate.models import Event, Trigger from frigate.models import Event, Trigger
from frigate.util.builtin import cosine_distance from frigate.util.builtin import cosine_distance
from frigate.util.file import get_event_thumbnail_bytes from frigate.util.path import get_event_thumbnail_bytes
from ..post.api import PostProcessorApi from ..post.api import PostProcessorApi
from ..types import DataProcessorMetrics from ..types import DataProcessorMetrics

View File

@ -466,7 +466,6 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
now, now,
self.labelmap[best_id], self.labelmap[best_id],
score, score,
max_files=200,
) )
if score < self.model_config.threshold: if score < self.model_config.threshold:
@ -530,7 +529,6 @@ def write_classification_attempt(
timestamp: float, timestamp: float,
label: str, label: str,
score: float, score: float,
max_files: int = 100,
) -> None: ) -> None:
if "-" in label: if "-" in label:
label = label.replace("-", "_") label = label.replace("-", "_")
@ -546,5 +544,5 @@ def write_classification_attempt(
) )
# delete oldest face image if maximum is reached # delete oldest face image if maximum is reached
if len(files) > max_files: if len(files) > 100:
os.unlink(os.path.join(folder, files[-1])) os.unlink(os.path.join(folder, files[-1]))

View File

@ -166,7 +166,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
camera = obj_data["camera"] camera = obj_data["camera"]
if not self.config.cameras[camera].face_recognition.enabled: if not self.config.cameras[camera].face_recognition.enabled:
logger.debug(f"Face recognition disabled for camera {camera}, skipping")
return return
start = datetime.datetime.now().timestamp() start = datetime.datetime.now().timestamp()
@ -209,7 +208,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
person_box = obj_data.get("box") person_box = obj_data.get("box")
if not person_box: if not person_box:
logger.debug(f"No person box available for {id}")
return return
rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420) rgb = cv2.cvtColor(frame, cv2.COLOR_YUV2RGB_I420)
@ -235,8 +233,7 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
try: try:
face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR) face_frame = cv2.cvtColor(face_frame, cv2.COLOR_RGB2BGR)
except Exception as e: except Exception:
logger.debug(f"Failed to convert face frame color for {id}: {e}")
return return
else: else:
# don't run for object without attributes # don't run for object without attributes
@ -254,7 +251,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
# no faces detected in this frame # no faces detected in this frame
if not face: if not face:
logger.debug(f"No face attributes found for {id}")
return return
face_box = face.get("box") face_box = face.get("box")
@ -278,7 +274,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
res = self.recognizer.classify(face_frame) res = self.recognizer.classify(face_frame)
if not res: if not res:
logger.debug(f"Face recognizer returned no result for {id}")
self.__update_metrics(datetime.datetime.now().timestamp() - start) self.__update_metrics(datetime.datetime.now().timestamp() - start)
return return
@ -335,7 +330,6 @@ class FaceRealTimeProcessor(RealTimeProcessorApi):
def handle_request(self, topic, request_data) -> dict[str, Any] | None: def handle_request(self, topic, request_data) -> dict[str, Any] | None:
if topic == EmbeddingsRequestEnum.clear_face_classifier.value: if topic == EmbeddingsRequestEnum.clear_face_classifier.value:
self.recognizer.clear() self.recognizer.clear()
return {"success": True, "message": "Face classifier cleared."}
elif topic == EmbeddingsRequestEnum.recognize_face.value: elif topic == EmbeddingsRequestEnum.recognize_face.value:
img = cv2.imdecode( img = cv2.imdecode(
np.frombuffer(base64.b64decode(request_data["image"]), dtype=np.uint8), np.frombuffer(base64.b64decode(request_data["image"]), dtype=np.uint8),

View File

@ -17,7 +17,6 @@ from frigate.detectors.detector_config import (
BaseDetectorConfig, BaseDetectorConfig,
ModelTypeEnum, ModelTypeEnum,
) )
from frigate.util.file import FileLock
from frigate.util.model import post_process_yolo from frigate.util.model import post_process_yolo
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -178,6 +177,29 @@ class MemryXDetector(DetectionApi):
logger.error(f"Failed to initialize MemryX model: {e}") logger.error(f"Failed to initialize MemryX model: {e}")
raise 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): def load_yolo_constants(self):
base = f"{self.cache_dir}/{self.model_folder}" base = f"{self.cache_dir}/{self.model_folder}"
# constants for yolov9 post-processing # constants for yolov9 post-processing
@ -190,9 +212,9 @@ class MemryXDetector(DetectionApi):
os.makedirs(self.cache_dir, exist_ok=True) os.makedirs(self.cache_dir, exist_ok=True)
lock_path = os.path.join(self.cache_dir, f".{self.model_folder}.lock") lock_path = os.path.join(self.cache_dir, f".{self.model_folder}.lock")
lock = FileLock(lock_path, timeout=60) self._acquire_file_lock(lock_path)
with lock: try:
# ---------- CASE 1: user provided a custom model path ---------- # ---------- CASE 1: user provided a custom model path ----------
if self.memx_model_path: if self.memx_model_path:
if not self.memx_model_path.endswith(".zip"): if not self.memx_model_path.endswith(".zip"):
@ -316,6 +338,9 @@ class MemryXDetector(DetectionApi):
f"Failed to remove downloaded zip {zip_path}: {e}" 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): def send_input(self, connection_id, tensor_input: np.ndarray):
"""Pre-process (if needed) and send frame to MemryX input queue""" """Pre-process (if needed) and send frame to MemryX input queue"""
if tensor_input is None: if tensor_input is None:

View File

@ -29,7 +29,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.models import Event, Trigger from frigate.models import Event, Trigger
from frigate.types import ModelStatusTypesEnum from frigate.types import ModelStatusTypesEnum
from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize from frigate.util.builtin import EventsPerSecond, InferenceSpeed, serialize
from frigate.util.file import get_event_thumbnail_bytes from frigate.util.path import get_event_thumbnail_bytes
from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding from .onnx.jina_v1_embedding import JinaV1ImageEmbedding, JinaV1TextEmbedding
from .onnx.jina_v2_embedding import JinaV2Embedding from .onnx.jina_v2_embedding import JinaV2Embedding

View File

@ -62,8 +62,8 @@ from frigate.events.types import EventTypeEnum, RegenerateDescriptionEnum
from frigate.genai import get_genai_client from frigate.genai import get_genai_client
from frigate.models import Event, Recordings, ReviewSegment, Trigger from frigate.models import Event, Recordings, ReviewSegment, Trigger
from frigate.util.builtin import serialize from frigate.util.builtin import serialize
from frigate.util.file import get_event_thumbnail_bytes
from frigate.util.image import SharedMemoryFrameManager from frigate.util.image import SharedMemoryFrameManager
from frigate.util.path import get_event_thumbnail_bytes
from .embeddings import Embeddings from .embeddings import Embeddings
@ -158,13 +158,11 @@ class EmbeddingMaintainer(threading.Thread):
self.realtime_processors: list[RealTimeProcessorApi] = [] self.realtime_processors: list[RealTimeProcessorApi] = []
if self.config.face_recognition.enabled: if self.config.face_recognition.enabled:
logger.debug("Face recognition enabled, initializing FaceRealTimeProcessor")
self.realtime_processors.append( self.realtime_processors.append(
FaceRealTimeProcessor( FaceRealTimeProcessor(
self.config, self.requestor, self.event_metadata_publisher, metrics self.config, self.requestor, self.event_metadata_publisher, metrics
) )
) )
logger.debug("FaceRealTimeProcessor initialized successfully")
if self.config.classification.bird.enabled: if self.config.classification.bird.enabled:
self.realtime_processors.append( self.realtime_processors.append(
@ -285,32 +283,11 @@ class EmbeddingMaintainer(threading.Thread):
logger.info("Exiting embeddings maintenance...") logger.info("Exiting embeddings maintenance...")
def _check_classification_config_updates(self) -> None: def _check_classification_config_updates(self) -> None:
"""Check for classification config updates and add/remove processors.""" """Check for classification config updates and add new processors."""
topic, model_config = self.classification_config_subscriber.check_for_update() topic, model_config = self.classification_config_subscriber.check_for_update()
if topic: if topic and model_config:
model_name = topic.split("/")[-1] model_name = topic.split("/")[-1]
if model_config is None:
self.realtime_processors = [
processor
for processor in self.realtime_processors
if not (
isinstance(
processor,
(
CustomStateClassificationProcessor,
CustomObjectClassificationProcessor,
),
)
and processor.model_config.name == model_name
)
]
logger.info(
f"Successfully removed classification processor for model: {model_name}"
)
else:
self.config.classification.custom[model_name] = model_config self.config.classification.custom[model_name] = model_config
# Check if processor already exists # Check if processor already exists
@ -397,14 +374,7 @@ class EmbeddingMaintainer(threading.Thread):
source_type, _, camera, frame_name, data = update source_type, _, camera, frame_name, data = update
logger.debug(
f"Received update - source_type: {source_type}, camera: {camera}, data label: {data.get('label') if data else 'None'}"
)
if not camera or source_type != EventTypeEnum.tracked_object: if not camera or source_type != EventTypeEnum.tracked_object:
logger.debug(
f"Skipping update - camera: {camera}, source_type: {source_type}"
)
return return
if self.config.semantic_search.enabled: if self.config.semantic_search.enabled:
@ -414,9 +384,6 @@ class EmbeddingMaintainer(threading.Thread):
# no need to process updated objects if no processors are active # no need to process updated objects if no processors are active
if len(self.realtime_processors) == 0 and len(self.post_processors) == 0: if len(self.realtime_processors) == 0 and len(self.post_processors) == 0:
logger.debug(
f"No processors active - realtime: {len(self.realtime_processors)}, post: {len(self.post_processors)}"
)
return return
# Create our own thumbnail based on the bounding box and the frame time # Create our own thumbnail based on the bounding box and the frame time
@ -425,7 +392,6 @@ class EmbeddingMaintainer(threading.Thread):
frame_name, camera_config.frame_shape_yuv frame_name, camera_config.frame_shape_yuv
) )
except FileNotFoundError: except FileNotFoundError:
logger.debug(f"Frame {frame_name} not found for camera {camera}")
pass pass
if yuv_frame is None: if yuv_frame is None:
@ -434,11 +400,7 @@ class EmbeddingMaintainer(threading.Thread):
) )
return return
logger.debug(
f"Processing {len(self.realtime_processors)} realtime processors for object {data.get('id')} (label: {data.get('label')})"
)
for processor in self.realtime_processors: for processor in self.realtime_processors:
logger.debug(f"Calling process_frame on {processor.__class__.__name__}")
processor.process_frame(data, yuv_frame) processor.process_frame(data, yuv_frame)
for processor in self.post_processors: for processor in self.post_processors:

View File

@ -12,7 +12,7 @@ from frigate.config import FrigateConfig
from frigate.const import CLIPS_DIR from frigate.const import CLIPS_DIR
from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.models import Event, Timeline from frigate.models import Event, Timeline
from frigate.util.file import delete_event_snapshot, delete_event_thumbnail from frigate.util.path import delete_event_snapshot, delete_event_thumbnail
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -114,7 +114,7 @@ Your response MUST be a flat JSON object with:
## Objects in Scene ## Objects in Scene
Each line represents a detection state, not necessarily unique individuals. Parentheses indicate object type or category, use only the name/label in your response, not the parentheses. Each line represents a detection state, not necessarily unique individuals. Objects with names in parentheses (e.g., "Name (person)") are verified identities. Objects without names (e.g., "Person") are detected but not identified.
**CRITICAL: When you see both recognized and unrecognized entries of the same type (e.g., "Joe (person)" and "Person"), visually count how many distinct people/objects you actually see based on appearance and clothing. If you observe only ONE person throughout the sequence, use ONLY the recognized name (e.g., "Joe"). The same person may be recognized in some frames but not others. Only describe both if you visually see MULTIPLE distinct people with clearly different appearances.** **CRITICAL: When you see both recognized and unrecognized entries of the same type (e.g., "Joe (person)" and "Person"), visually count how many distinct people/objects you actually see based on appearance and clothing. If you observe only ONE person throughout the sequence, use ONLY the recognized name (e.g., "Joe"). The same person may be recognized in some frames but not others. Only describe both if you visually see MULTIPLE distinct people with clearly different appearances.**

View File

@ -18,7 +18,6 @@ class OpenAIClient(GenAIClient):
"""Generative AI client for Frigate using OpenAI.""" """Generative AI client for Frigate using OpenAI."""
provider: OpenAI provider: OpenAI
context_size: Optional[int] = None
def _init_provider(self): def _init_provider(self):
"""Initialize the client.""" """Initialize the client."""
@ -70,33 +69,5 @@ class OpenAIClient(GenAIClient):
def get_context_size(self) -> int: def get_context_size(self) -> int:
"""Get the context window size for OpenAI.""" """Get the context window size for OpenAI."""
if self.context_size is not None: # OpenAI GPT-4 Vision models have 128K token context window
return self.context_size return 128000
try:
models = self.provider.models.list()
for model in models.data:
if model.id == self.genai_config.model:
if hasattr(model, "max_model_len") and model.max_model_len:
self.context_size = model.max_model_len
logger.debug(
f"Retrieved context size {self.context_size} for model {self.genai_config.model}"
)
return self.context_size
except Exception as e:
logger.debug(
f"Failed to fetch model context size from API: {e}, using default"
)
# Default to 128K for ChatGPT models, 8K for others
model_name = self.genai_config.model.lower()
if "gpt" in model_name:
self.context_size = 128000
else:
self.context_size = 8192
logger.debug(
f"Using default context size {self.context_size} for model {self.genai_config.model}"
)
return self.context_size

View File

@ -9,7 +9,6 @@ from multiprocessing import Queue, Value
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
import numpy as np import numpy as np
import zmq
from frigate.comms.object_detector_signaler import ( from frigate.comms.object_detector_signaler import (
ObjectDetectorPublisher, ObjectDetectorPublisher,
@ -378,15 +377,6 @@ class RemoteObjectDetector:
if self.stop_event.is_set(): if self.stop_event.is_set():
return detections return detections
# Drain any stale detection results from the ZMQ buffer before making a new request
# This prevents reading detection results from a previous request
# NOTE: This should never happen, but can in some rare cases
while True:
try:
self.detector_subscriber.socket.recv_string(flags=zmq.NOBLOCK)
except zmq.Again:
break
# copy input to shared memory # copy input to shared memory
self.np_shm[:] = tensor_input[:] self.np_shm[:] = tensor_input[:]
self.detection_queue.put(self.name) self.detection_queue.put(self.name)

View File

@ -14,8 +14,7 @@ from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum
from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR from frigate.const import CACHE_DIR, CLIPS_DIR, MAX_WAL_SIZE, RECORD_DIR
from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus from frigate.models import Previews, Recordings, ReviewSegment, UserReviewStatus
from frigate.record.util import remove_empty_directories, sync_recordings from frigate.record.util import remove_empty_directories, sync_recordings
from frigate.util.builtin import clear_and_unlink from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time
from frigate.util.time import get_tomorrow_at_time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -28,7 +28,7 @@ from frigate.ffmpeg_presets import (
parse_preset_hardware_acceleration_encode, parse_preset_hardware_acceleration_encode,
) )
from frigate.models import Export, Previews, Recordings from frigate.models import Export, Previews, Recordings
from frigate.util.time import is_current_hour from frigate.util.builtin import is_current_hour
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -15,9 +15,12 @@ from collections.abc import Mapping
from multiprocessing.sharedctypes import Synchronized from multiprocessing.sharedctypes import Synchronized
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, Tuple, Union from typing import Any, Dict, Optional, Tuple, Union
from zoneinfo import ZoneInfoNotFoundError
import numpy as np import numpy as np
import pytz
from ruamel.yaml import YAML from ruamel.yaml import YAML
from tzlocal import get_localzone
from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS from frigate.const import REGEX_HTTP_CAMERA_USER_PASS, REGEX_RTSP_CAMERA_USER_PASS
@ -154,6 +157,17 @@ def load_labels(path: Optional[str], encoding="utf-8", prefill=91):
return labels return labels
def get_tz_modifiers(tz_name: str) -> Tuple[str, str, float]:
seconds_offset = (
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
)
hours_offset = int(seconds_offset / 60 / 60)
minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
hour_modifier = f"{hours_offset} hour"
minute_modifier = f"{minutes_offset} minute"
return hour_modifier, minute_modifier, seconds_offset
def to_relative_box( def to_relative_box(
width: int, height: int, box: Tuple[int, int, int, int] width: int, height: int, box: Tuple[int, int, int, int]
) -> Tuple[int | float, int | float, int | float, int | float]: ) -> Tuple[int | float, int | float, int | float, int | float]:
@ -284,6 +298,34 @@ def find_by_key(dictionary, target_key):
return None return None
def get_tomorrow_at_time(hour: int) -> datetime.datetime:
"""Returns the datetime of the following day at 2am."""
try:
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
except ZoneInfoNotFoundError:
tomorrow = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
days=1
)
logger.warning(
"Using utc for maintenance due to missing or incorrect timezone set"
)
return tomorrow.replace(hour=hour, minute=0, second=0).astimezone(
datetime.timezone.utc
)
def is_current_hour(timestamp: int) -> bool:
"""Returns if timestamp is in the current UTC hour."""
start_of_next_hour = (
datetime.datetime.now(datetime.timezone.utc).replace(
minute=0, second=0, microsecond=0
)
+ datetime.timedelta(hours=1)
).timestamp()
return timestamp < start_of_next_hour
def clear_and_unlink(file: Path, missing_ok: bool = True) -> None: def clear_and_unlink(file: Path, missing_ok: bool = True) -> None:
"""clear file then unlink to avoid space retained by file descriptors.""" """clear file then unlink to avoid space retained by file descriptors."""
if not missing_ok and not file.exists(): if not missing_ok and not file.exists():

View File

@ -20,8 +20,8 @@ from frigate.const import (
from frigate.log import redirect_output_to_logger from frigate.log import redirect_output_to_logger
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.types import ModelStatusTypesEnum 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.image import get_image_from_recording
from frigate.util.path import get_event_thumbnail_bytes
from frigate.util.process import FrigateProcess from frigate.util.process import FrigateProcess
BATCH_SIZE = 16 BATCH_SIZE = 16

View File

@ -384,10 +384,10 @@ def migrate_017_0(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]
new_object_config["genai"] = {} new_object_config["genai"] = {}
for key in global_genai.keys(): for key in global_genai.keys():
if key in ["model", "provider", "base_url", "api_key"]: if key not in ["enabled", "model", "provider", "base_url", "api_key"]:
new_genai_config[key] = global_genai[key]
else:
new_object_config["genai"][key] = global_genai[key] new_object_config["genai"][key] = global_genai[key]
else:
new_genai_config[key] = global_genai[key]
config["genai"] = new_genai_config config["genai"] = new_genai_config

View File

@ -1,6 +1,7 @@
import logging import logging
import os import os
import threading import threading
import time
from pathlib import Path from pathlib import Path
from typing import Callable, List from typing import Callable, List
@ -9,11 +10,40 @@ import requests
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.const import UPDATE_MODEL_STATE from frigate.const import UPDATE_MODEL_STATE
from frigate.types import ModelStatusTypesEnum from frigate.types import ModelStatusTypesEnum
from frigate.util.file import FileLock
logger = logging.getLogger(__name__) 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: class ModelDownloader:
def __init__( def __init__(
self, self,
@ -51,13 +81,15 @@ class ModelDownloader:
def _download_models(self): def _download_models(self):
for file_name in self.file_names: for file_name in self.file_names:
path = os.path.join(self.download_path, file_name) path = os.path.join(self.download_path, file_name)
lock_path = f"{path}.lock" lock = FileLock(path)
lock = FileLock(lock_path, cleanup_stale_on_init=True)
if not os.path.exists(path): if not os.path.exists(path):
with lock: lock.acquire()
try:
if not os.path.exists(path): if not os.path.exists(path):
self.download_func(path) self.download_func(path)
finally:
lock.release()
self.requestor.send_data( self.requestor.send_data(
UPDATE_MODEL_STATE, UPDATE_MODEL_STATE,

View File

@ -1,276 +0,0 @@
"""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()

62
frigate/util/path.py Normal file
View File

@ -0,0 +1,62 @@
"""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

View File

@ -1,5 +1,6 @@
"""RKNN model conversion utility for Frigate.""" """RKNN model conversion utility for Frigate."""
import fcntl
import logging import logging
import os import os
import subprocess import subprocess
@ -8,8 +9,6 @@ import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from frigate.util.file import FileLock
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODEL_TYPE_CONFIGS = { MODEL_TYPE_CONFIGS = {
@ -246,6 +245,112 @@ def convert_onnx_to_rknn(
logger.warning(f"Failed to remove temporary ONNX file: {e}") 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( def wait_for_conversion_completion(
model_type: str, rknn_path: Path, lock_file_path: Path, timeout: int = 300 model_type: str, rknn_path: Path, lock_file_path: Path, timeout: int = 300
) -> bool: ) -> bool:
@ -253,7 +358,6 @@ def wait_for_conversion_completion(
Wait for another process to complete the conversion. Wait for another process to complete the conversion.
Args: Args:
model_type: Type of model being converted
rknn_path: Path to the expected RKNN model rknn_path: Path to the expected RKNN model
lock_file_path: Path to the lock file to monitor lock_file_path: Path to the lock file to monitor
timeout: Maximum time to wait in seconds timeout: Maximum time to wait in seconds
@ -262,8 +366,6 @@ def wait_for_conversion_completion(
True if RKNN model appears, False if timeout True if RKNN model appears, False if timeout
""" """
start_time = time.time() start_time = time.time()
lock = FileLock(lock_file_path, stale_timeout=600)
while time.time() - start_time < timeout: while time.time() - start_time < timeout:
# Check if RKNN model appeared # Check if RKNN model appeared
if rknn_path.exists(): if rknn_path.exists():
@ -283,14 +385,11 @@ def wait_for_conversion_completion(
return False return False
# Check if lock is stale # Check if lock is stale
if lock.is_stale(): if is_lock_stale(lock_file_path):
logger.warning("Lock file is stale, attempting to clean up and retry...") logger.warning("Lock file is stale, attempting to clean up and retry...")
lock._cleanup_stale_lock() cleanup_stale_lock(lock_file_path)
# Try to acquire lock again # Try to acquire lock again
retry_lock = FileLock( if acquire_conversion_lock(lock_file_path, timeout=60):
lock_file_path, timeout=60, cleanup_stale_on_init=True
)
if retry_lock.acquire():
try: try:
# Check if RKNN file appeared while waiting # Check if RKNN file appeared while waiting
if rknn_path.exists(): if rknn_path.exists():
@ -316,7 +415,7 @@ def wait_for_conversion_completion(
return False return False
finally: finally:
retry_lock.release() release_conversion_lock(lock_file_path)
logger.debug("Waiting for RKNN model to appear...") logger.debug("Waiting for RKNN model to appear...")
time.sleep(1) time.sleep(1)
@ -353,9 +452,8 @@ def auto_convert_model(
return str(rknn_path) return str(rknn_path)
lock_file_path = base_path.parent / f"{base_name}.conversion.lock" lock_file_path = base_path.parent / f"{base_name}.conversion.lock"
lock = FileLock(lock_file_path, timeout=300, cleanup_stale_on_init=True)
if lock.acquire(): if acquire_conversion_lock(lock_file_path):
try: try:
if rknn_path.exists(): if rknn_path.exists():
logger.info( logger.info(
@ -378,7 +476,7 @@ def auto_convert_model(
return None return None
finally: finally:
lock.release() release_conversion_lock(lock_file_path)
else: else:
logger.info( logger.info(
f"Another process is converting {model_path}, waiting for completion..." f"Another process is converting {model_path}, waiting for completion..."

View File

@ -1,100 +0,0 @@
"""Time utilities."""
import datetime
import logging
from typing import Tuple
from zoneinfo import ZoneInfoNotFoundError
import pytz
from tzlocal import get_localzone
logger = logging.getLogger(__name__)
def get_tz_modifiers(tz_name: str) -> Tuple[str, str, float]:
seconds_offset = (
datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds()
)
hours_offset = int(seconds_offset / 60 / 60)
minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
hour_modifier = f"{hours_offset} hour"
minute_modifier = f"{minutes_offset} minute"
return hour_modifier, minute_modifier, seconds_offset
def get_tomorrow_at_time(hour: int) -> datetime.datetime:
"""Returns the datetime of the following day at 2am."""
try:
tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1)
except ZoneInfoNotFoundError:
tomorrow = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
days=1
)
logger.warning(
"Using utc for maintenance due to missing or incorrect timezone set"
)
return tomorrow.replace(hour=hour, minute=0, second=0).astimezone(
datetime.timezone.utc
)
def is_current_hour(timestamp: int) -> bool:
"""Returns if timestamp is in the current UTC hour."""
start_of_next_hour = (
datetime.datetime.now(datetime.timezone.utc).replace(
minute=0, second=0, microsecond=0
)
+ datetime.timedelta(hours=1)
).timestamp()
return timestamp < start_of_next_hour
def get_dst_transitions(
tz_name: str, start_time: float, end_time: float
) -> list[tuple[float, float]]:
"""
Find DST transition points and return time periods with consistent offsets.
Args:
tz_name: Timezone name (e.g., 'America/New_York')
start_time: Start timestamp (UTC)
end_time: End timestamp (UTC)
Returns:
List of (period_start, period_end, seconds_offset) tuples representing
continuous periods with the same UTC offset
"""
try:
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
# If timezone is invalid, return single period with no offset
return [(start_time, end_time, 0)]
periods = []
current = start_time
# Get initial offset
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
local_dt = dt.astimezone(tz)
prev_offset = local_dt.utcoffset().total_seconds()
period_start = start_time
# Check each day for offset changes
while current <= end_time:
dt = datetime.datetime.utcfromtimestamp(current).replace(tzinfo=pytz.UTC)
local_dt = dt.astimezone(tz)
current_offset = local_dt.utcoffset().total_seconds()
if current_offset != prev_offset:
# Found a transition - close previous period
periods.append((period_start, current, prev_offset))
period_start = current
prev_offset = current_offset
current += 86400 # Check daily
# Add final period
periods.append((period_start, end_time, prev_offset))
return periods

View File

@ -34,7 +34,7 @@ from frigate.ptz.autotrack import ptz_moving_at_frame_time
from frigate.track import ObjectTracker from frigate.track import ObjectTracker
from frigate.track.norfair_tracker import NorfairTracker from frigate.track.norfair_tracker import NorfairTracker
from frigate.track.tracked_object import TrackedObjectAttribute from frigate.track.tracked_object import TrackedObjectAttribute
from frigate.util.builtin import EventsPerSecond from frigate.util.builtin import EventsPerSecond, get_tomorrow_at_time
from frigate.util.image import ( from frigate.util.image import (
FrameManager, FrameManager,
SharedMemoryFrameManager, SharedMemoryFrameManager,
@ -53,7 +53,6 @@ from frigate.util.object import (
reduce_detections, reduce_detections,
) )
from frigate.util.process import FrigateProcess from frigate.util.process import FrigateProcess
from frigate.util.time import get_tomorrow_at_time
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -100,8 +100,7 @@
}, },
"list": { "list": {
"two": "{{0}} and {{1}}", "two": "{{0}} and {{1}}",
"many": "{{items}}, and {{last}}", "many": "{{items}}, and {{last}}"
"separatorWithSpace": ", "
}, },
"field": { "field": {
"optional": "Optional", "optional": "Optional",

View File

@ -1,53 +1,31 @@
{ {
"documentTitle": "Classification Models", "documentTitle": "Classification Models",
"details": {
"scoreInfo": "Score represents the average classification confidence across all detections of this object."
},
"button": { "button": {
"deleteClassificationAttempts": "Delete Classification Images", "deleteClassificationAttempts": "Delete Classification Images",
"renameCategory": "Rename Class", "renameCategory": "Rename Class",
"deleteCategory": "Delete Class", "deleteCategory": "Delete Class",
"deleteImages": "Delete Images", "deleteImages": "Delete Images",
"trainModel": "Train Model", "trainModel": "Train Model"
"addClassification": "Add Classification",
"deleteModels": "Delete Models",
"editModel": "Edit Model"
}, },
"toast": { "toast": {
"success": { "success": {
"deletedCategory": "Deleted Class", "deletedCategory": "Deleted Class",
"deletedImage": "Deleted Images", "deletedImage": "Deleted Images",
"deletedModel_one": "Successfully deleted {{count}} model",
"deletedModel_other": "Successfully deleted {{count}} models",
"categorizedImage": "Successfully Classified Image", "categorizedImage": "Successfully Classified Image",
"trainedModel": "Successfully trained model.", "trainedModel": "Successfully trained model.",
"trainingModel": "Successfully started model training.", "trainingModel": "Successfully started model training."
"updatedModel": "Successfully updated model configuration"
}, },
"error": { "error": {
"deleteImageFailed": "Failed to delete: {{errorMessage}}", "deleteImageFailed": "Failed to delete: {{errorMessage}}",
"deleteCategoryFailed": "Failed to delete class: {{errorMessage}}", "deleteCategoryFailed": "Failed to delete class: {{errorMessage}}",
"deleteModelFailed": "Failed to delete model: {{errorMessage}}",
"categorizeFailed": "Failed to categorize image: {{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": { "deleteCategory": {
"title": "Delete Class", "title": "Delete Class",
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model." "desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model."
}, },
"deleteModel": {
"title": "Delete Classification Model",
"single": "Are you sure you want to delete {{name}}? This will permanently delete all associated data including images and training data. This action cannot be undone.",
"desc": "Are you sure you want to delete {{count}} model(s)? This will permanently delete all associated data including images and training data. This action cannot be undone."
},
"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": { "deleteDatasetImages": {
"title": "Delete Dataset Images", "title": "Delete Dataset Images",
"desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model." "desc": "Are you sure you want to delete {{count}} images from {{dataset}}? This action cannot be undone and will require re-training the model."
@ -74,10 +52,6 @@
}, },
"categorizeImageAs": "Classify Image As:", "categorizeImageAs": "Classify Image As:",
"categorizeImage": "Classify Image", "categorizeImage": "Classify Image",
"menu": {
"objects": "Objects",
"states": "States"
},
"noModels": { "noModels": {
"object": { "object": {
"title": "No Object Classification Models", "title": "No Object Classification Models",
@ -112,7 +86,6 @@
"classificationSubLabel": "Sub Label", "classificationSubLabel": "Sub Label",
"classificationAttribute": "Attribute", "classificationAttribute": "Attribute",
"classes": "Classes", "classes": "Classes",
"states": "States",
"classesTip": "Learn about classes", "classesTip": "Learn about classes",
"classesStateDesc": "Define the different states your camera area can be in. For example: 'open' and 'closed' for a garage door.", "classesStateDesc": "Define the different states your camera area can be in. For example: 'open' and 'closed' for a garage door.",
"classesObjectDesc": "Define the different categories to classify detected objects into. For example: 'delivery_person', 'resident', 'stranger' for person classification.", "classesObjectDesc": "Define the different categories to classify detected objects into. For example: 'delivery_person', 'resident', 'stranger' for person classification.",

View File

@ -33,7 +33,6 @@
"type": { "type": {
"details": "details", "details": "details",
"snapshot": "snapshot", "snapshot": "snapshot",
"thumbnail": "thumbnail",
"video": "video", "video": "video",
"object_lifecycle": "object lifecycle" "object_lifecycle": "object lifecycle"
}, },
@ -42,7 +41,7 @@
"noImageFound": "No image found for this timestamp.", "noImageFound": "No image found for this timestamp.",
"createObjectMask": "Create Object Mask", "createObjectMask": "Create Object Mask",
"adjustAnnotationSettings": "Adjust annotation settings", "adjustAnnotationSettings": "Adjust annotation settings",
"scrollViewTips": "Click to view the significant moments of this object's lifecycle.", "scrollViewTips": "Scroll to view the significant moments of this object's lifecycle.",
"autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.", "autoTrackingTips": "Bounding box positions will be inaccurate for autotracking cameras.",
"count": "{{first}} of {{second}}", "count": "{{first}} of {{second}}",
"trackedPoint": "Tracked Point", "trackedPoint": "Tracked Point",

View File

@ -6,8 +6,7 @@
}, },
"details": { "details": {
"timestamp": "Timestamp", "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", "documentTitle": "Face Library - Frigate",
"uploadFaceImage": { "uploadFaceImage": {

View File

@ -271,8 +271,6 @@
"disconnectStream": "Disconnect", "disconnectStream": "Disconnect",
"estimatedBandwidth": "Estimated Bandwidth", "estimatedBandwidth": "Estimated Bandwidth",
"roles": "Roles", "roles": "Roles",
"ffmpegModule": "Use stream compatibility mode",
"ffmpegModuleDescription": "If the stream does not load after several attempts, try enabling this. When enabled, Frigate will use the ffmpeg module with go2rtc. This may provide better compatibility with some camera streams.",
"none": "None", "none": "None",
"error": "Error", "error": "Error",
"streamValidated": "Stream {{number}} validated successfully", "streamValidated": "Stream {{number}} validated successfully",

View File

@ -7,12 +7,11 @@ import {
} from "@/types/classification"; } from "@/types/classification";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import { forwardRef, useMemo, useRef, useState } from "react"; import { forwardRef, useMemo, useRef, useState } from "react";
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { LuSearch } from "react-icons/lu";
import { LuSearch, LuInfo } from "react-icons/lu";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { HiSquare2Stack } from "react-icons/hi2"; import { HiSquare2Stack } from "react-icons/hi2";
@ -182,7 +181,6 @@ type GroupedClassificationCardProps = {
selectedItems: string[]; selectedItems: string[];
i18nLibrary: string; i18nLibrary: string;
objectType: string; objectType: string;
noClassificationLabel?: string;
onClick: (data: ClassificationItemData | undefined) => void; onClick: (data: ClassificationItemData | undefined) => void;
children?: (data: ClassificationItemData) => React.ReactNode; children?: (data: ClassificationItemData) => React.ReactNode;
}; };
@ -192,7 +190,6 @@ export function GroupedClassificationCard({
threshold, threshold,
selectedItems, selectedItems,
i18nLibrary, i18nLibrary,
noClassificationLabel = "details.none",
onClick, onClick,
children, children,
}: GroupedClassificationCardProps) { }: GroupedClassificationCardProps) {
@ -225,14 +222,10 @@ export function GroupedClassificationCard({
const bestTyped: ClassificationItemData = best; const bestTyped: ClassificationItemData = best;
return { return {
...bestTyped, ...bestTyped,
name: event name: event ? (event.sub_label ?? t("details.unknown")) : bestTyped.name,
? event.sub_label && event.sub_label !== "none"
? event.sub_label
: t(noClassificationLabel)
: bestTyped.name,
score: event?.data?.sub_label_score || bestTyped.score, score: event?.data?.sub_label_score || bestTyped.score,
}; };
}, [group, event, noClassificationLabel, t]); }, [group, event, t]);
const bestScoreStatus = useMemo(() => { const bestScoreStatus = useMemo(() => {
if (!bestItem?.score || !threshold) { if (!bestItem?.score || !threshold) {
@ -264,8 +257,8 @@ export function GroupedClassificationCard({
const Overlay = isDesktop ? Dialog : MobilePage; const Overlay = isDesktop ? Dialog : MobilePage;
const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger; const Trigger = isDesktop ? DialogTrigger : MobilePageTrigger;
const Content = isDesktop ? DialogContent : MobilePageContent;
const Header = isDesktop ? DialogHeader : MobilePageHeader; const Header = isDesktop ? DialogHeader : MobilePageHeader;
const Content = isDesktop ? DialogContent : MobilePageContent;
const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle; const ContentTitle = isDesktop ? DialogTitle : MobilePageTitle;
const ContentDescription = isDesktop const ContentDescription = isDesktop
? DialogDescription ? DialogDescription
@ -298,9 +291,9 @@ export function GroupedClassificationCard({
<Trigger asChild></Trigger> <Trigger asChild></Trigger>
<Content <Content
className={cn( className={cn(
"scrollbar-container", "",
isDesktop && "min-w-[50%] max-w-[65%]", isDesktop && "min-w-[50%] max-w-[65%]",
isMobile && "overflow-y-auto", isMobile && "flex flex-col",
)} )}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
@ -308,21 +301,18 @@ export function GroupedClassificationCard({
<Header <Header
className={cn( className={cn(
"mx-2 flex flex-row items-center gap-4", "mx-2 flex flex-row items-center gap-4",
isMobileOnly && "top-0 mx-4", isMobile && "flex-shrink-0",
)} )}
> >
<div <div>
<ContentTitle
className={cn( className={cn(
"", "flex items-center gap-2 font-normal capitalize",
isMobile && "flex flex-col items-center justify-center", isMobile && "px-2",
)} )}
> >
<ContentTitle className="flex items-center gap-2 font-normal capitalize"> {event?.sub_label ? event.sub_label : t("details.unknown")}
{event?.sub_label && event.sub_label !== "none" {event?.sub_label && (
? event.sub_label
: t(noClassificationLabel)}
{event?.sub_label && event.sub_label !== "none" && (
<div className="flex items-center gap-1">
<div <div
className={cn( className={cn(
"", "",
@ -331,22 +321,6 @@ export function GroupedClassificationCard({
bestScoreStatus == "unknown" && "text-danger", bestScoreStatus == "unknown" && "text-danger",
)} )}
>{`${Math.round((event.data.sub_label_score || 0) * 100)}%`}</div> >{`${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> </ContentTitle>
<ContentDescription className={cn("", isMobile && "px-2")}> <ContentDescription className={cn("", isMobile && "px-2")}>
@ -390,7 +364,7 @@ export function GroupedClassificationCard({
className={cn( 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", "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", isDesktop && "p-2",
isMobile && "px-4 pb-4", isMobile && "scrollbar-container flex-1 overflow-y-auto",
)} )}
> >
{group.map((data: ClassificationItemData) => ( {group.map((data: ClassificationItemData) => (

View File

@ -37,8 +37,6 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { Button, buttonVariants } from "../ui/button"; import { Button, buttonVariants } from "../ui/button";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LuCircle } from "react-icons/lu";
import { MdAutoAwesome } from "react-icons/md";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@ -144,7 +142,7 @@ export default function ReviewCard({
className={cn( className={cn(
"size-full rounded-lg", "size-full rounded-lg",
activeReviewItem?.id == event.id && activeReviewItem?.id == event.id &&
"outline outline-[3px] -outline-offset-[2.8px] outline-selected duration-200", "outline outline-[3px] outline-offset-1 outline-selected",
imgLoaded ? "visible" : "invisible", imgLoaded ? "visible" : "invisible",
)} )}
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`} src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
@ -165,33 +163,21 @@ export default function ReviewCard({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex items-center gap-2"> <div className="flex items-center justify-evenly gap-1">
<LuCircle <>
className={cn( {event.data.objects.map((object) => {
"size-2", return getIconForLabel(
event.severity == "alert" object,
? "fill-severity_alert text-severity_alert" "size-3 text-primary dark:text-white",
: "fill-severity_detection text-severity_detection", );
)} })}
/> {event.data.audio.map((audio) => {
<div className="flex items-center gap-1"> return getIconForLabel(
{event.data.objects.map((object, idx) => ( audio,
<div "size-3 text-primary dark:text-white",
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 className="font-extra-light text-xs">{formattedDate}</div>
</div> </div>
</TooltipTrigger> </TooltipTrigger>
@ -218,14 +204,6 @@ export default function ReviewCard({
dense dense
/> />
</div> </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> </div>
); );

View File

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

View File

@ -394,9 +394,7 @@ export default function Step1NameAndDefine({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FormLabel className="text-primary-variant"> <FormLabel className="text-primary-variant">
{watchedModelType === "state" {t("wizard.step1.classes")}
? t("wizard.step1.states")
: t("wizard.step1.classes")}
</FormLabel> </FormLabel>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>

View File

@ -317,21 +317,6 @@ export default function Step3ChooseExamples({
return unclassifiedImages.length === 0; return unclassifiedImages.length === 0;
}, [unclassifiedImages]); }, [unclassifiedImages]);
const handleBack = useCallback(() => {
if (currentClassIndex > 0) {
const previousClass = allClasses[currentClassIndex - 1];
setCurrentClassIndex((prev) => prev - 1);
// Restore selections for the previous class
const previousSelections = Object.entries(imageClassifications)
.filter(([_, className]) => className === previousClass)
.map(([imageName, _]) => imageName);
setSelectedImages(new Set(previousSelections));
} else {
onBack();
}
}, [currentClassIndex, allClasses, imageClassifications, onBack]);
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{isTraining ? ( {isTraining ? (
@ -435,7 +420,7 @@ export default function Step3ChooseExamples({
{!isTraining && ( {!isTraining && (
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4"> <div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button type="button" onClick={handleBack} className="sm:flex-1"> <Button type="button" onClick={onBack} className="sm:flex-1">
{t("button.back", { ns: "common" })} {t("button.back", { ns: "common" })}
</Button> </Button>
<Button <Button

View File

@ -348,26 +348,6 @@ export function GeneralFilterContent({
onClose, onClose,
}: GeneralFilterContentProps) { }: GeneralFilterContentProps) {
const { t } = useTranslation(["components/filter"]); const { t } = useTranslation(["components/filter"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allAudioListenLabels = useMemo<string[]>(() => {
if (!config) {
return [];
}
const labels = new Set<string>();
Object.values(config.cameras).forEach((camera) => {
if (camera?.audio?.enabled) {
camera.audio.listen.forEach((label) => {
labels.add(label);
});
}
});
return [...labels].sort();
}, [config]);
return ( return (
<> <>
<div className="overflow-x-hidden"> <div className="overflow-x-hidden">
@ -393,10 +373,7 @@ export function GeneralFilterContent({
{allLabels.map((item) => ( {allLabels.map((item) => (
<FilterSwitch <FilterSwitch
key={item} key={item}
label={getTranslatedLabel( label={getTranslatedLabel(item)}
item,
allAudioListenLabels.includes(item) ? "audio" : "object",
)}
isChecked={currentLabels?.includes(item) ?? false} isChecked={currentLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked) {

View File

@ -8,7 +8,7 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect } from "react";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { generateFixedHash, isValidId } from "@/utils/stringUtil"; import { generateFixedHash, isValidId } from "@/utils/stringUtil";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -25,7 +25,6 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
processId?: (name: string) => string; processId?: (name: string) => string;
placeholderName?: string; placeholderName?: string;
placeholderId?: string; placeholderId?: string;
idVisible?: boolean;
}; };
export default function NameAndIdFields<T extends FieldValues = FieldValues>({ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
@ -40,12 +39,10 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
processId, processId,
placeholderName, placeholderName,
placeholderId, placeholderId,
idVisible,
}: NameAndIdFieldsProps<T>) { }: NameAndIdFieldsProps<T>) {
const { t } = useTranslation(["common"]); const { t } = useTranslation(["common"]);
const { watch, setValue, trigger, formState } = useFormContext<T>(); const { watch, setValue, trigger } = useFormContext<T>();
const [isIdVisible, setIsIdVisible] = useState(idVisible ?? false); const [isIdVisible, setIsIdVisible] = useState(false);
const hasUserTypedRef = useRef(false);
const defaultProcessId = (name: string) => { const defaultProcessId = (name: string) => {
const normalized = name.replace(/\s+/g, "_").toLowerCase(); const normalized = name.replace(/\s+/g, "_").toLowerCase();
@ -61,7 +58,6 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
useEffect(() => { useEffect(() => {
const subscription = watch((value, { name }) => { const subscription = watch((value, { name }) => {
if (name === nameField) { if (name === nameField) {
hasUserTypedRef.current = true;
const processedId = effectiveProcessId(value[nameField] || ""); const processedId = effectiveProcessId(value[nameField] || "");
setValue(idField, processedId as PathValue<T, Path<T>>); setValue(idField, processedId as PathValue<T, Path<T>>);
trigger(idField); trigger(idField);
@ -70,14 +66,6 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [watch, setValue, trigger, nameField, idField, effectiveProcessId]); }, [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 ( return (
<> <>
<FormField <FormField

View File

@ -258,7 +258,6 @@ export default function CreateTriggerDialog({
nameLabel={t("triggers.dialog.form.name.title")} nameLabel={t("triggers.dialog.form.name.title")}
nameDescription={t("triggers.dialog.form.name.description")} nameDescription={t("triggers.dialog.form.name.description")}
placeholderName={t("triggers.dialog.form.name.placeholder")} placeholderName={t("triggers.dialog.form.name.placeholder")}
idVisible={!!trigger}
/> />
<FormField <FormField

View File

@ -13,9 +13,6 @@ import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
// Use a small tolerance (10ms) for browsers with seek precision by-design issues
const TOLERANCE = 0.01;
type ObjectTrackOverlayProps = { type ObjectTrackOverlayProps = {
camera: string; camera: string;
showBoundingBoxes?: boolean; showBoundingBoxes?: boolean;
@ -58,47 +55,6 @@ export default function ObjectTrackOverlay({
const effectiveCurrentTime = currentTime - annotationOffset / 1000; const effectiveCurrentTime = currentTime - annotationOffset / 1000;
const {
pathStroke,
pointRadius,
pointStroke,
zoneStroke,
boxStroke,
highlightRadius,
} = useMemo(() => {
const BASE_WIDTH = 1280;
const BASE_HEIGHT = 720;
const BASE_PATH_STROKE = 5;
const BASE_POINT_RADIUS = 7;
const BASE_POINT_STROKE = 3;
const BASE_ZONE_STROKE = 5;
const BASE_BOX_STROKE = 5;
const BASE_HIGHLIGHT_RADIUS = 5;
const scale = Math.sqrt(
(videoWidth * videoHeight) / (BASE_WIDTH * BASE_HEIGHT),
);
const pathStroke = Math.max(1, Math.round(BASE_PATH_STROKE * scale));
const pointRadius = Math.max(2, Math.round(BASE_POINT_RADIUS * scale));
const pointStroke = Math.max(1, Math.round(BASE_POINT_STROKE * scale));
const zoneStroke = Math.max(1, Math.round(BASE_ZONE_STROKE * scale));
const boxStroke = Math.max(1, Math.round(BASE_BOX_STROKE * scale));
const highlightRadius = Math.max(
2,
Math.round(BASE_HIGHLIGHT_RADIUS * scale),
);
return {
pathStroke,
pointRadius,
pointStroke,
zoneStroke,
boxStroke,
highlightRadius,
};
}, [videoWidth, videoHeight]);
// Fetch all event data in a single request (CSV ids) // Fetch all event data in a single request (CSV ids)
const { data: eventsData } = useSWR<Event[]>( const { data: eventsData } = useSWR<Event[]>(
selectedObjectIds.length > 0 selectedObjectIds.length > 0
@ -210,50 +166,41 @@ export default function ObjectTrackOverlay({
}) || []; }) || [];
// show full path once current time has reached the object's start time // show full path once current time has reached the object's start time
// event.start_time is in DETECT stream time, so convert it to record stream time for comparison const combinedPoints = [...savedPathPoints, ...eventSequencePoints]
const eventStartTimeRecord = .sort((a, b) => a.timestamp - b.timestamp)
(eventData?.start_time ?? 0) + annotationOffset / 1000; .filter(
const allPoints = [...savedPathPoints, ...eventSequencePoints].sort(
(a, b) => a.timestamp - b.timestamp,
);
const combinedPoints = allPoints.filter(
(point) => (point) =>
currentTime >= eventStartTimeRecord - TOLERANCE && currentTime >= (eventData?.start_time ?? 0) &&
point.timestamp <= effectiveCurrentTime + TOLERANCE, point.timestamp >= (eventData?.start_time ?? 0) &&
point.timestamp <= (eventData?.end_time ?? Infinity),
); );
// Get color for this object // Get color for this object
const label = eventData?.label || "unknown"; const label = eventData?.label || "unknown";
const color = getObjectColor(label, objectId); const color = getObjectColor(label, objectId);
// zones (with tolerance for browsers with seek precision by-design issues) // Get current zones
const currentZones = const currentZones =
timelineData timelineData
?.filter( ?.filter(
(event: TrackingDetailsSequence) => (event: TrackingDetailsSequence) =>
event.timestamp <= effectiveCurrentTime + TOLERANCE, event.timestamp <= effectiveCurrentTime,
) )
.sort( .sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) => (a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
b.timestamp - a.timestamp, b.timestamp - a.timestamp,
)[0]?.data?.zones || []; )[0]?.data?.zones || [];
// bounding box - only show if there's a timeline event at/near the current time with a box // Get current bounding box
// Search all timeline events (not just those before current time) to find one matching the seek position const currentBox = timelineData
const nearbyTimelineEvent = timelineData ?.filter(
?.filter((event: TrackingDetailsSequence) => event.data.box) (event: TrackingDetailsSequence) =>
event.timestamp <= effectiveCurrentTime && event.data.box,
)
.sort( .sort(
(a: TrackingDetailsSequence, b: TrackingDetailsSequence) => (a: TrackingDetailsSequence, b: TrackingDetailsSequence) =>
Math.abs(a.timestamp - effectiveCurrentTime) - b.timestamp - a.timestamp,
Math.abs(b.timestamp - effectiveCurrentTime), )[0]?.data?.box;
)
.find(
(event: TrackingDetailsSequence) =>
Math.abs(event.timestamp - effectiveCurrentTime) <= TOLERANCE,
);
const currentBox = nearbyTimelineEvent?.data?.box;
return { return {
objectId, objectId,
@ -274,7 +221,6 @@ export default function ObjectTrackOverlay({
getObjectColor, getObjectColor,
config, config,
camera, camera,
annotationOffset,
]); ]);
// Collect all zones across all objects // Collect all zones across all objects
@ -328,10 +274,9 @@ export default function ObjectTrackOverlay({
const handlePointClick = useCallback( const handlePointClick = useCallback(
(timestamp: number) => { (timestamp: number) => {
// Convert detect stream timestamp to record stream timestamp before seeking onSeekToTime?.(timestamp, false);
onSeekToTime?.(timestamp + annotationOffset / 1000, false);
}, },
[onSeekToTime, annotationOffset], [onSeekToTime],
); );
const zonePolygons = useMemo(() => { const zonePolygons = useMemo(() => {
@ -379,7 +324,7 @@ export default function ObjectTrackOverlay({
points={zone.points} points={zone.points}
fill={zone.fill} fill={zone.fill}
stroke={zone.stroke} stroke={zone.stroke}
strokeWidth={zoneStroke} strokeWidth="5"
opacity="0.7" opacity="0.7"
/> />
))} ))}
@ -399,7 +344,7 @@ export default function ObjectTrackOverlay({
d={generateStraightPath(absolutePositions)} d={generateStraightPath(absolutePositions)}
fill="none" fill="none"
stroke={objData.color} stroke={objData.color}
strokeWidth={pathStroke} strokeWidth="5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
@ -411,13 +356,13 @@ export default function ObjectTrackOverlay({
<circle <circle
cx={pos.x} cx={pos.x}
cy={pos.y} cy={pos.y}
r={pointRadius} r="7"
fill={getPointColor( fill={getPointColor(
objData.color, objData.color,
pos.lifecycle_item?.class_type, pos.lifecycle_item?.class_type,
)} )}
stroke="white" stroke="white"
strokeWidth={pointStroke} strokeWidth="3"
style={{ cursor: onSeekToTime ? "pointer" : "default" }} style={{ cursor: onSeekToTime ? "pointer" : "default" }}
onClick={() => handlePointClick(pos.timestamp)} onClick={() => handlePointClick(pos.timestamp)}
/> />
@ -446,7 +391,7 @@ export default function ObjectTrackOverlay({
height={objData.currentBox[3] * videoHeight} height={objData.currentBox[3] * videoHeight}
fill="none" fill="none"
stroke={objData.color} stroke={objData.color}
strokeWidth={boxStroke} strokeWidth="5"
opacity="0.9" opacity="0.9"
/> />
<circle <circle
@ -458,10 +403,10 @@ export default function ObjectTrackOverlay({
(objData.currentBox[1] + objData.currentBox[3]) * (objData.currentBox[1] + objData.currentBox[3]) *
videoHeight videoHeight
} }
r={highlightRadius} r="5"
fill="rgb(255, 255, 0)" // yellow highlight fill="rgb(255, 255, 0)" // yellow highlight
stroke={objData.color} stroke={objData.color}
strokeWidth={boxStroke} strokeWidth="5"
opacity="1" opacity="1"
/> />
</g> </g>

View File

@ -91,8 +91,8 @@ export default function AnnotationOffsetSlider({ className }: Props) {
<div className="w-full flex-1 landscape:flex"> <div className="w-full flex-1 landscape:flex">
<Slider <Slider
value={[annotationOffset]} value={[annotationOffset]}
min={-2500} min={-1500}
max={2500} max={1500}
step={50} step={50}
onValueChange={handleChange} onValueChange={handleChange}
/> />

View File

@ -0,0 +1,577 @@
import { isDesktop, isIOS, isMobile } from "react-device-detect";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "../../ui/sheet";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getIconForLabel } from "@/utils/iconUtil";
import { useApiHost } from "@/api";
import {
ReviewDetailPaneType,
ReviewSegment,
ThreatLevel,
} from "@/types/review";
import { Event } from "@/types/event";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
import TrackingDetails from "./TrackingDetails";
import Chip from "@/components/indicators/Chip";
import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { FaArrowsRotate } from "react-icons/fa6";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { baseUrl } from "@/api/baseUrl";
import { shareOrCopy } from "@/utils/browserUtil";
import {
MobilePage,
MobilePageContent,
MobilePageDescription,
MobilePageHeader,
MobilePageTitle,
} from "@/components/mobile/MobilePage";
import { DownloadVideoButton } from "@/components/button/DownloadVideoButton";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { LuSearch } from "react-icons/lu";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type ReviewDetailDialogProps = {
review?: ReviewSegment;
setReview: (review: ReviewSegment | undefined) => void;
};
export default function ReviewDetailDialog({
review,
setReview,
}: ReviewDetailDialogProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const navigate = useNavigate();
// upload
const [upload, setUpload] = useState<Event>();
// data
const { data: events } = useSWR<Event[]>(
review ? ["event_ids", { ids: review.data.detections.join(",") }] : null,
);
const aiAnalysis = useMemo(() => review?.data?.metadata, [review]);
const aiThreatLevel = useMemo(() => {
if (
!aiAnalysis ||
(!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
) {
return "None";
}
let concerns = "";
switch (aiAnalysis.potential_threat_level) {
case ThreatLevel.SUSPICIOUS:
concerns = `${t("suspiciousActivity", { ns: "views/events" })}\n`;
break;
case ThreatLevel.DANGER:
concerns = `${t("threateningActivity", { ns: "views/events" })}\n`;
break;
}
(aiAnalysis.other_concerns ?? []).forEach((c) => {
concerns += `${c}\n`;
});
return concerns || "None";
}, [aiAnalysis, t]);
const hasMismatch = useMemo(() => {
if (!review || !events) {
return false;
}
return events.length != review?.data.detections.length;
}, [review, events]);
const missingObjects = useMemo(() => {
if (!review || !events) {
return [];
}
const detectedIds = review.data.detections;
const missing = Array.from(
new Set(
events
.filter((event) => !detectedIds.includes(event.id))
.map((event) => event.label),
),
);
return missing;
}, [review, events]);
const formattedDate = useFormattedTimestamp(
review?.start_time ?? 0,
config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayYearHourMinute.24hour", {
ns: "common",
})
: t("time.formattedTimestampMonthDayYearHourMinute.12hour", {
ns: "common",
}),
config?.ui.timezone,
);
// content
const [selectedEvent, setSelectedEvent] = useState<Event>();
const [pane, setPane] = useState<ReviewDetailPaneType>("overview");
// dialog and mobile page
const [isOpen, setIsOpen] = useState(review != undefined);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
if (!open) {
// short timeout to allow the mobile page animation
// to complete before updating the state
setTimeout(() => {
setReview(undefined);
setSelectedEvent(undefined);
setPane("overview");
}, 300);
}
},
[setReview, setIsOpen],
);
useEffect(() => {
setIsOpen(review != undefined);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [review]);
// keyboard listener
useKeyboardListener(["Esc"], (key, modifiers) => {
if (key == "Esc" && modifiers.down && !modifiers.repeat) {
setIsOpen(false);
}
return true;
});
const Overlay = isDesktop ? Sheet : MobilePage;
const Content = isDesktop ? SheetContent : MobilePageContent;
const Header = isDesktop ? SheetHeader : MobilePageHeader;
const Title = isDesktop ? SheetTitle : MobilePageTitle;
const Description = isDesktop ? SheetDescription : MobilePageDescription;
if (!review) {
return;
}
return (
<>
<Overlay
open={isOpen ?? false}
onOpenChange={handleOpenChange}
enableHistoryBack={true}
>
<FrigatePlusDialog
upload={upload}
onClose={() => setUpload(undefined)}
onEventUploaded={() => {
if (upload) {
upload.plus_id = "new_upload";
}
}}
/>
<Content
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop && pane == "overview"
? "sm:max-w-xl"
: "pt-2 sm:max-w-4xl",
isMobile && "px-4",
)}
>
<span tabIndex={0} className="sr-only" />
{pane == "overview" && (
<Header className="justify-center">
<Title>{t("details.item.title")}</Title>
<Description className="sr-only">
{t("details.item.desc")}
</Description>
<div
className={cn(
"absolute flex gap-2 lg:flex-col",
isDesktop && "right-1 top-8",
isMobile && "right-0 top-3",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t("details.item.button.share")}
size="sm"
onClick={() =>
shareOrCopy(`${baseUrl}review?id=${review.id}`)
}
>
<FaShareAlt className="size-4 text-secondary-foreground" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.share")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<DownloadVideoButton
source={`${baseUrl}api/${review.camera}/start/${review.start_time}/end/${review.end_time || Date.now() / 1000}/clip.mp4`}
camera={review.camera}
startTime={review.start_time}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</Header>
)}
{pane == "overview" && (
<div className="flex flex-col gap-5 md:mt-3">
{aiAnalysis != undefined && (
<div
className={cn(
"flex h-full w-full flex-col gap-2 rounded-md bg-card p-2",
isDesktop && "m-2 w-[90%]",
)}
>
{t("aiAnalysis.title")}
<div className="text-sm text-primary/40">
{t("details.description.label")}
</div>
<div className="text-sm">{aiAnalysis.scene}</div>
<div className="text-sm text-primary/40">
{t("details.score.label")}
</div>
<div className="text-sm">{aiAnalysis.confidence * 100}%</div>
<div className="text-sm text-primary/40">
{t("concerns.label")}
</div>
<div className="text-sm">{aiThreatLevel}</div>
</div>
)}
<div className="flex w-full flex-row">
<div className="flex w-full flex-col gap-3">
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.camera")}
</div>
<div className="text-sm smart-capitalize">
<CameraNameLabel camera={review.camera} />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.timestamp")}
</div>
<div className="text-sm">{formattedDate}</div>
</div>
</div>
<div className="flex w-full flex-col items-center gap-2">
<div className="flex w-full flex-col gap-1.5 lg:pr-8">
<div className="text-sm text-primary/40">
{t("details.objects")}
</div>
<div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-auto text-sm smart-capitalize">
{events?.map((event) => {
return (
<div
key={event.id}
className="flex flex-row items-center gap-2 smart-capitalize"
>
{getIconForLabel(
event.label,
"size-3 text-primary",
)}
{event.sub_label ??
event.label.replaceAll("_", " ")}{" "}
({Math.round(event.data.top_score * 100)}%)
<Tooltip>
<TooltipTrigger>
<div
className="cursor-pointer"
onClick={() => {
navigate(`/explore?event_id=${event.id}`);
}}
>
<LuSearch className="size-4 text-muted-foreground" />
</div>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("details.item.button.viewInExplore")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
);
})}
</div>
</div>
{review.data.zones.length > 0 && (
<div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
<div className="text-sm text-primary/40">
{t("details.zones")}
</div>
<div className="flex flex-col items-start gap-2 text-sm smart-capitalize">
{review.data.zones.map((zone) => {
return (
<div
key={zone}
className="flex flex-row items-center gap-2 smart-capitalize"
>
{zone.replaceAll("_", " ")}
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{hasMismatch && (
<div className="p-4 text-center text-sm">
{(() => {
const detectedCount = Math.abs(
(events?.length ?? 0) -
(review?.data.detections.length ?? 0),
);
return t("details.item.tips.mismatch", {
count: detectedCount,
});
})()}
{missingObjects.length > 0 && (
<div className="mt-2">
<Trans
ns="views/explore"
values={{
objects: missingObjects
.map((x) => getTranslatedLabel(x))
.join(", "),
}}
>
details.item.tips.hasMissingObjects
</Trans>
</div>
)}
</div>
)}
<div className="relative flex size-full flex-col gap-2">
{events?.map((event) => (
<EventItem
key={event.id}
event={event}
setPane={setPane}
setSelectedEvent={setSelectedEvent}
setUpload={setUpload}
/>
))}
</div>
</div>
)}
{pane == "details" && selectedEvent && (
<div className="mt-0 flex size-full flex-col gap-2">
<TrackingDetails event={selectedEvent} setPane={setPane} />
</div>
)}
</Content>
</Overlay>
</>
);
}
type EventItemProps = {
event: Event;
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
setSelectedEvent: React.Dispatch<React.SetStateAction<Event | undefined>>;
setUpload?: React.Dispatch<React.SetStateAction<Event | undefined>>;
};
function EventItem({
event,
setPane,
setSelectedEvent,
setUpload,
}: EventItemProps) {
const { t } = useTranslation(["views/explore"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const apiHost = useApiHost();
const imgRef = useRef(null);
const [hovered, setHovered] = useState(isMobile);
const navigate = useNavigate();
return (
<>
<div
className={cn(
"relative mr-auto",
!event.has_snapshot && "flex flex-row items-center justify-center",
)}
onMouseEnter={isDesktop ? () => setHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
key={event.id}
>
{event.has_snapshot && (
<>
<div className="pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl"></div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl"></div>
</>
)}
<img
ref={imgRef}
className={cn(
"select-none rounded-lg object-contain transition-opacity",
)}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.webp`
}
/>
{hovered && (
<div>
<div
className={cn("absolute right-1 top-1 flex items-center gap-2")}
>
<Tooltip>
<TooltipTrigger asChild>
<a
download
href={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.webp`
}
>
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">
<FaDownload className="size-4 text-white" />
</Chip>
</a>
</TooltipTrigger>
<TooltipContent>
{t("button.download", { ns: "common" })}
</TooltipContent>
</Tooltip>
{event.has_snapshot &&
event.plus_id == undefined &&
event.data.type == "object" &&
config?.plus.enabled && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
setUpload?.(event);
}}
>
<FrigatePlusIcon className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.submitToPlus.label")}
</TooltipContent>
</Tooltip>
)}
{event.has_clip && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
setPane("details");
setSelectedEvent(event);
}}
>
<FaArrowsRotate className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.viewTrackingDetails.label")}
</TooltipContent>
</Tooltip>
)}
{event.has_snapshot && config?.semantic_search.enabled && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
navigate(
`/explore?search_type=similarity&event_id=${event.id}`,
);
}}
>
<FaImages className="size-4 text-white" />
</Chip>
</TooltipTrigger>
<TooltipContent>
{t("itemMenu.findSimilar.label")}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
)}
</div>
</>
);
}

View File

@ -31,9 +31,10 @@ import {
FaDownload, FaDownload,
FaHistory, FaHistory,
FaImage, FaImage,
FaRegListAlt,
FaVideo,
} from "react-icons/fa"; } from "react-icons/fa";
import { TrackingDetails } from "./TrackingDetails"; import TrackingDetails from "./TrackingDetails";
import { DetailStreamProvider } from "@/context/detail-stream-context";
import { import {
MobilePage, MobilePage,
MobilePageContent, MobilePageContent,
@ -79,9 +80,13 @@ import { getTranslatedLabel } from "@/utils/i18n";
import { CgTranscript } from "react-icons/cg"; import { CgTranscript } from "react-icons/cg";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { PiPath } from "react-icons/pi"; import { PiPath } from "react-icons/pi";
import Heading from "@/components/ui/heading";
const SEARCH_TABS = ["snapshot", "tracking_details"] as const; const SEARCH_TABS = [
"details",
"snapshot",
"video",
"tracking_details",
] as const;
export type SearchTab = (typeof SEARCH_TABS)[number]; export type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = { type SearchDetailDialogProps = {
@ -104,7 +109,6 @@ export default function SearchDetailDialog({
const { data: config } = useSWR<FrigateConfig>("config", { const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
const apiHost = useApiHost();
// tabs // tabs
@ -145,6 +149,16 @@ export default function SearchDetailDialog({
const views = [...SEARCH_TABS]; const views = [...SEARCH_TABS];
if (!search.has_snapshot) {
const index = views.indexOf("snapshot");
views.splice(index, 1);
}
if (!search.has_clip) {
const index = views.indexOf("video");
views.splice(index, 1);
}
if (search.data.type != "object" || !search.has_clip) { if (search.data.type != "object" || !search.has_clip) {
const index = views.indexOf("tracking_details"); const index = views.indexOf("tracking_details");
views.splice(index, 1); views.splice(index, 1);
@ -159,50 +173,10 @@ export default function SearchDetailDialog({
} }
if (!searchTabs.includes(pageToggle)) { if (!searchTabs.includes(pageToggle)) {
setSearchPage("snapshot"); setSearchPage("details");
} }
}, [pageToggle, searchTabs, setSearchPage]); }, [pageToggle, searchTabs, setSearchPage]);
// Tabs component for reuse
const tabsComponent = (
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SearchTab) => {
if (value) {
setPageToggle(value);
}
}}
>
{Object.values(searchTabs).map((item) => (
<ToggleGroupItem
key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
data-nav-item={item}
aria-label={`Select ${item}`}
>
{item == "snapshot" && <FaImage className="size-4" />}
{item == "tracking_details" && <PiPath className="size-4" />}
<div className="smart-capitalize">
{item === "snapshot"
? search?.has_snapshot
? t("type.snapshot")
: t("type.thumbnail")
: t(`type.${item}`)}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
);
if (!search) { if (!search) {
return; return;
} }
@ -216,12 +190,6 @@ export default function SearchDetailDialog({
const Description = isDesktop ? DialogDescription : MobilePageDescription; const Description = isDesktop ? DialogDescription : MobilePageDescription;
return ( return (
<DetailStreamProvider
isDetailMode={true}
currentTime={(search as unknown as Event)?.start_time ?? 0}
camera={(search as unknown as Event)?.camera ?? ""}
initialSelectedObjectIds={[(search as unknown as Event).id as string]}
>
<Overlay <Overlay
open={isOpen} open={isOpen}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
@ -232,9 +200,6 @@ export default function SearchDetailDialog({
"scrollbar-container overflow-y-auto", "scrollbar-container overflow-y-auto",
isDesktop && isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl", "max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
isDesktop &&
page == "tracking_details" &&
"lg:max-w-[75%] xl:max-w-[80%]",
isMobile && "px-4", isMobile && "px-4",
)} )}
> >
@ -244,71 +209,6 @@ export default function SearchDetailDialog({
{t("trackedObjectDetails")} {t("trackedObjectDetails")}
</Description> </Description>
</Header> </Header>
{isDesktop ? (
page === "tracking_details" ? (
<TrackingDetails
className="size-full"
event={search as unknown as Event}
tabs={tabsComponent}
/>
) : (
<div className="flex h-full gap-4 overflow-hidden">
<div
className={cn(
"scrollbar-container flex-[3] overflow-y-hidden",
page === "snapshot" && !search.has_snapshot && "flex-[2]",
)}
>
{page === "snapshot" && search.has_snapshot && (
<ObjectSnapshotTab
search={
{
...search,
plus_id: config?.plus?.enabled
? search.plus_id
: "not_enabled",
} as unknown as Event
}
onEventUploaded={() => {
search.plus_id = "new_upload";
}}
/>
)}
{page === "snapshot" && !search.has_snapshot && (
<img
className="size-full select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
)}
</div>
<div className="flex flex-[2] flex-col gap-4 overflow-hidden">
{tabsComponent}
<div className="scrollbar-container flex-1 overflow-y-auto">
{page == "snapshot" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
)}
</div>
</div>
</div>
)
) : (
<>
<ScrollArea <ScrollArea
className={cn("w-full whitespace-nowrap", isMobile && "my-2")} className={cn("w-full whitespace-nowrap", isMobile && "my-2")}
> >
@ -327,34 +227,37 @@ export default function SearchDetailDialog({
{Object.values(searchTabs).map((item) => ( {Object.values(searchTabs).map((item) => (
<ToggleGroupItem <ToggleGroupItem
key={item} key={item}
className={`flex scroll-mx-10 items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`} className={`flex scroll-mx-10 items-center justify-between gap-2 ${page == "details" ? "last:mr-20" : ""} ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item} value={item}
data-nav-item={item} data-nav-item={item}
aria-label={`Select ${item}`} aria-label={`Select ${item}`}
> >
{item == "details" && <FaRegListAlt className="size-4" />}
{item == "snapshot" && <FaImage className="size-4" />} {item == "snapshot" && <FaImage className="size-4" />}
{item == "tracking_details" && ( {item == "video" && <FaVideo className="size-4" />}
<PiPath className="size-4" /> {item == "tracking_details" && <PiPath className="size-4" />}
)} <div className="smart-capitalize">{t(`type.${item}`)}</div>
<div className="smart-capitalize">
{t(`type.${item}`)}
</div>
</ToggleGroupItem> </ToggleGroupItem>
))} ))}
</ToggleGroup> </ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" /> <ScrollBar orientation="horizontal" className="h-0" />
</div> </div>
</ScrollArea> </ScrollArea>
{page == "details" && (
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
/>
)}
{page == "snapshot" && ( {page == "snapshot" && (
<>
{search.has_snapshot && (
<ObjectSnapshotTab <ObjectSnapshotTab
search={ search={
{ {
...search, ...search,
plus_id: config?.plus?.enabled plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled",
? search.plus_id
: "not_enabled",
} as unknown as Event } as unknown as Event
} }
onEventUploaded={() => { onEventUploaded={() => {
@ -362,42 +265,17 @@ export default function SearchDetailDialog({
}} }}
/> />
)} )}
{page == "snapshot" && !search.has_snapshot && ( {page == "video" && <VideoTab search={search} />}
<img
className="w-full select-none rounded-lg object-contain transition-opacity"
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/>
)}
<Heading as="h3" className="mt-2 smart-capitalize">
{t("type.details")}
</Heading>
<ObjectDetailsTab
search={search}
config={config}
setSearch={setSearch}
setSimilarity={setSimilarity}
setInputFocused={setInputFocused}
showThumbnail={false}
/>
</>
)}
{page == "tracking_details" && ( {page == "tracking_details" && (
<TrackingDetails event={search as unknown as Event} /> <TrackingDetails
)} className="w-full overflow-x-hidden"
</> event={search as unknown as Event}
fullscreen={true}
setPane={() => {}}
/>
)} )}
</Content> </Content>
</Overlay> </Overlay>
</DetailStreamProvider>
); );
} }
@ -407,7 +285,6 @@ type ObjectDetailsTabProps = {
setSearch: (search: SearchResult | undefined) => void; setSearch: (search: SearchResult | undefined) => void;
setSimilarity?: () => void; setSimilarity?: () => void;
setInputFocused: React.Dispatch<React.SetStateAction<boolean>>; setInputFocused: React.Dispatch<React.SetStateAction<boolean>>;
showThumbnail?: boolean;
}; };
function ObjectDetailsTab({ function ObjectDetailsTab({
search, search,
@ -415,7 +292,6 @@ function ObjectDetailsTab({
setSearch, setSearch,
setSimilarity, setSimilarity,
setInputFocused, setInputFocused,
showThumbnail = true,
}: ObjectDetailsTabProps) { }: ObjectDetailsTabProps) {
const { t } = useTranslation(["views/explore", "views/faceLibrary"]); const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
@ -997,7 +873,6 @@ function ObjectDetailsTab({
<div className="text-sm">{formattedDate}</div> <div className="text-sm">{formattedDate}</div>
</div> </div>
</div> </div>
{showThumbnail && (
<div className="flex w-full flex-col gap-2 pl-6"> <div className="flex w-full flex-col gap-2 pl-6">
<img <img
className="aspect-video select-none rounded-lg object-contain transition-opacity" className="aspect-video select-none rounded-lg object-contain transition-opacity"
@ -1013,10 +888,7 @@ function ObjectDetailsTab({
src={`${apiHost}api/events/${search.id}/thumbnail.webp`} src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/> />
<div <div
className={cn( className={cn("flex w-full flex-row gap-2", isMobile && "flex-col")}
"flex w-full flex-row gap-2",
isMobile && "flex-col",
)}
> >
{config?.semantic_search.enabled && {config?.semantic_search.enabled &&
setSimilarity != undefined && setSimilarity != undefined &&
@ -1061,7 +933,6 @@ function ObjectDetailsTab({
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{config?.cameras[search.camera].objects.genai.enabled && {config?.cameras[search.camera].objects.genai.enabled &&
@ -1296,7 +1167,7 @@ export function ObjectSnapshotTab({
search.label != "on_demand" && ( search.label != "on_demand" && (
<Card className="p-1 text-sm md:p-2"> <Card className="p-1 text-sm md:p-2">
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row"> <CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div className={cn("flex max-w-sm flex-col space-y-3")}> <div className={cn("flex flex-col space-y-3")}>
<div className={"text-lg leading-none"}> <div className={"text-lg leading-none"}>
{t("explore.plus.submitToPlus.label")} {t("explore.plus.submitToPlus.label")}
</div> </div>
@ -1305,7 +1176,7 @@ export function ObjectSnapshotTab({
</div> </div>
</div> </div>
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:flex-1 md:justify-end"> <div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end">
{state == "reviewing" && ( {state == "reviewing" && (
<> <>
<div> <div>

View File

@ -5,11 +5,29 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TrackingDetailsSequence } from "@/types/timeline"; import { TrackingDetailsSequence } from "@/types/timeline";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { ReviewDetailPaneType } from "@/types/review";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { LuCircle, LuFolderX, LuSettings } from "react-icons/lu"; import {
LuCircle,
LuCircleDot,
LuEar,
LuFolderX,
LuPlay,
LuSettings,
LuTruck,
} from "react-icons/lu";
import { IoMdArrowRoundBack, IoMdExit } from "react-icons/io";
import {
MdFaceUnlock,
MdOutlineLocationOn,
MdOutlinePictureInPictureAlt,
} from "react-icons/md";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useApiHost } from "@/api";
import { isDesktop, isIOS, isSafari } from "react-device-detect";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -17,10 +35,12 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { AnnotationSettingsPane } from "./AnnotationSettingsPane"; import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; import {
import { baseUrl } from "@/api/baseUrl"; ContextMenu,
import { REVIEW_PADDING } from "@/types/review"; ContextMenuContent,
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -29,53 +49,30 @@ import {
DropdownMenuPortal, DropdownMenuPortal,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ObjectPath } from "./ObjectPath";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { IoPlayCircleOutline } from "react-icons/io5";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { HiDotsHorizontal } from "react-icons/hi"; import { HiDotsHorizontal } from "react-icons/hi";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { useDetailStream } from "@/context/detail-stream-context";
import { isDesktop, isIOS, isMobileOnly, isSafari } from "react-device-detect";
import Chip from "@/components/indicators/Chip";
import { FaDownload, FaHistory } from "react-icons/fa";
import { useApiHost } from "@/api";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import ObjectTrackOverlay from "../ObjectTrackOverlay";
type TrackingDetailsProps = { type TrackingDetailsProps = {
className?: string; className?: string;
event: Event; event: Event;
fullscreen?: boolean; fullscreen?: boolean;
tabs?: React.ReactNode; setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
}; };
export function TrackingDetails({ export default function TrackingDetails({
className, className,
event, event,
tabs, fullscreen = false,
setPane,
}: TrackingDetailsProps) { }: TrackingDetailsProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
const navigate = useNavigate();
const apiHost = useApiHost();
const imgRef = useRef<HTMLImageElement | null>(null);
const [imgLoaded, setImgLoaded] = useState(false);
const [displaySource, _setDisplaySource] = useState<"video" | "image">(
"video",
);
const { setSelectedObjectIds, annotationOffset, setAnnotationOffset } =
useDetailStream();
// manualOverride holds a record-stream timestamp explicitly chosen by the
// user (eg, clicking a lifecycle row). When null we display `currentTime`.
const [manualOverride, setManualOverride] = useState<number | null>(null);
// event.start_time is detect time, convert to record, then subtract padding
const [currentTime, setCurrentTime] = useState(
(event.start_time ?? 0) + annotationOffset / 1000 - REVIEW_PADDING,
);
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([ const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
"timeline", "timeline",
@ -85,21 +82,16 @@ export function TrackingDetails({
]); ]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const apiHost = useApiHost();
const navigate = useNavigate();
// Use manualOverride (set when seeking in image mode) if present so const [imgLoaded, setImgLoaded] = useState(false);
// lifecycle rows and overlays follow image-mode seeks. Otherwise fall const imgRef = useRef<HTMLImageElement>(null);
// back to currentTime used for video mode.
const effectiveTime = useMemo(() => {
const displayedRecordTime = manualOverride ?? currentTime;
return displayedRecordTime - annotationOffset / 1000;
}, [manualOverride, currentTime, annotationOffset]);
const containerRef = useRef<HTMLDivElement | null>(null); const [selectedZone, setSelectedZone] = useState("");
const [_selectedZone, setSelectedZone] = useState(""); const [lifecycleZones, setLifecycleZones] = useState<string[]>([]);
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const [showZones, setShowZones] = useState(true); const [showZones, setShowZones] = useState(true);
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
const aspectRatio = useMemo(() => { const aspectRatio = useMemo(() => {
if (!config) { if (!config) {
@ -128,37 +120,178 @@ export function TrackingDetails({
[config, event], [config, event],
); );
// Set the selected object ID in the context so ObjectTrackOverlay can display it const getObjectColor = useCallback(
useEffect(() => { (label: string) => {
setSelectedObjectIds([event.id]); const objectColor = config?.model?.colormap[label];
}, [event.id, setSelectedObjectIds]); if (objectColor) {
const reversed = [...objectColor].reverse();
const handleLifecycleClick = useCallback( return reversed;
(item: TrackingDetailsSequence) => {
if (!videoRef.current && !imgRef.current) return;
// Convert lifecycle timestamp (detect stream) to record stream time
const targetTimeRecord = item.timestamp + annotationOffset / 1000;
if (displaySource === "image") {
// For image mode: set a manual override timestamp and update
// currentTime so overlays render correctly.
setManualOverride(targetTimeRecord);
setCurrentTime(targetTimeRecord);
return;
}
// For video mode: convert to video-relative time and seek player
const eventStartRecord =
(event.start_time ?? 0) + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const relativeTime = targetTimeRecord - videoStartTime;
if (videoRef.current) {
videoRef.current.currentTime = relativeTime;
} }
}, },
[event.start_time, annotationOffset, displaySource], [config],
);
const getZonePolygon = useCallback(
(zoneName: string) => {
if (!imgRef.current || !config) {
return;
}
const zonePoints =
config?.cameras[event.camera].zones[zoneName].coordinates;
const imgElement = imgRef.current;
const imgRect = imgElement.getBoundingClientRect();
return zonePoints
.split(",")
.map(Number.parseFloat)
.reduce((acc, value, index) => {
const isXCoordinate = index % 2 === 0;
const coordinate = isXCoordinate
? value * imgRect.width
: value * imgRect.height;
acc.push(coordinate);
return acc;
}, [] as number[])
.join(",");
},
[config, imgRef, event],
);
const [boxStyle, setBoxStyle] = useState<React.CSSProperties | null>(null);
const [attributeBoxStyle, setAttributeBoxStyle] =
useState<React.CSSProperties | null>(null);
const configAnnotationOffset = useMemo(() => {
if (!config) {
return 0;
}
return config.cameras[event.camera]?.detect?.annotation_offset || 0;
}, [config, event]);
const [annotationOffset, setAnnotationOffset] = useState<number>(
configAnnotationOffset,
);
const savedPathPoints = useMemo(() => {
return (
event.data.path_data?.map(([coords, timestamp]: [number[], number]) => ({
x: coords[0],
y: coords[1],
timestamp,
lifecycle_item: undefined,
})) || []
);
}, [event.data.path_data]);
const eventSequencePoints = useMemo(() => {
return (
eventSequence
?.filter((event) => event.data.box !== undefined)
.map((event) => {
const [left, top, width, height] = event.data.box!;
return {
x: left + width / 2, // Center x-coordinate
y: top + height, // Bottom y-coordinate
timestamp: event.timestamp,
lifecycle_item: event,
};
}) || []
);
}, [eventSequence]);
// final object path with timeline points included
const pathPoints = useMemo(() => {
// don't display a path if we don't have any saved path points
if (
savedPathPoints.length === 0 ||
config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config
)
return [];
return [...savedPathPoints, ...eventSequencePoints].sort(
(a, b) => a.timestamp - b.timestamp,
);
}, [savedPathPoints, eventSequencePoints, config, event]);
const [timeIndex, setTimeIndex] = useState(0);
const handleSetBox = useCallback(
(box: number[], attrBox: number[] | undefined) => {
if (imgRef.current && Array.isArray(box) && box.length === 4) {
const imgElement = imgRef.current;
const imgRect = imgElement.getBoundingClientRect();
const style = {
left: `${box[0] * imgRect.width}px`,
top: `${box[1] * imgRect.height}px`,
width: `${box[2] * imgRect.width}px`,
height: `${box[3] * imgRect.height}px`,
borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`,
};
if (attrBox) {
const attrStyle = {
left: `${attrBox[0] * imgRect.width}px`,
top: `${attrBox[1] * imgRect.height}px`,
width: `${attrBox[2] * imgRect.width}px`,
height: `${attrBox[3] * imgRect.height}px`,
borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`,
};
setAttributeBoxStyle(attrStyle);
} else {
setAttributeBoxStyle(null);
}
setBoxStyle(style);
}
},
[imgRef, event, getObjectColor],
);
// image
const [src, setSrc] = useState(
`${apiHost}api/${event.camera}/recordings/${event.start_time + annotationOffset / 1000}/snapshot.jpg?height=500`,
);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (timeIndex) {
const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`;
setSrc(newSrc);
}
setImgLoaded(false);
setHasError(false);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeIndex, annotationOffset]);
// carousels
// Selected lifecycle item index; -1 when viewing a path-only point
const handlePathPointClick = useCallback(
(index: number) => {
if (!eventSequence) return;
const sequenceIndex = eventSequence.findIndex(
(item) => item.timestamp === pathPoints[index].timestamp,
);
if (sequenceIndex !== -1) {
setTimeIndex(eventSequence[sequenceIndex].timestamp);
handleSetBox(
eventSequence[sequenceIndex]?.data.box ?? [],
eventSequence[sequenceIndex]?.data?.attribute_box,
);
setLifecycleZones(eventSequence[sequenceIndex]?.data.zones);
} else {
// click on a normal path point, not a lifecycle point
setTimeIndex(pathPoints[index].timestamp);
setBoxStyle(null);
setLifecycleZones([]);
}
},
[eventSequence, pathPoints, handleSetBox],
); );
const formattedStart = config const formattedStart = config
@ -195,54 +328,53 @@ export function TrackingDetails({
useEffect(() => { useEffect(() => {
if (!eventSequence || eventSequence.length === 0) return; if (!eventSequence || eventSequence.length === 0) return;
// If timeIndex hasn't been set to a non-zero value, prefer the first lifecycle timestamp
if (!timeIndex) {
setTimeIndex(eventSequence[0].timestamp);
handleSetBox(
eventSequence[0]?.data.box ?? [],
eventSequence[0]?.data?.attribute_box,
);
setLifecycleZones(eventSequence[0]?.data.zones); setLifecycleZones(eventSequence[0]?.data.zones);
}, [eventSequence]); }
}, [eventSequence, timeIndex, handleSetBox]);
// When timeIndex changes or image finishes loading, sync the box/zones to matching lifecycle, else clear
useEffect(() => { useEffect(() => {
if (seekToTimestamp === null) return; if (!eventSequence || timeIndex == null) return;
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
if (displaySource === "image") { if (idx !== -1) {
// For image mode, set the manual override so the snapshot updates to if (imgLoaded) {
// the exact record timestamp. handleSetBox(
setManualOverride(seekToTimestamp); eventSequence[idx]?.data.box ?? [],
setSeekToTimestamp(null); eventSequence[idx]?.data?.attribute_box,
return; );
} }
setLifecycleZones(eventSequence[idx]?.data.zones);
// seekToTimestamp is a record stream timestamp } else {
// event.start_time is detect stream time, convert to record // Non-lifecycle point (e.g., saved path point)
// The video clip starts at (eventStartRecord - REVIEW_PADDING) setBoxStyle(null);
if (!videoRef.current) return; setLifecycleZones([]);
const eventStartRecord = event.start_time + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const relativeTime = seekToTimestamp - videoStartTime;
if (relativeTime >= 0) {
videoRef.current.currentTime = relativeTime;
} }
setSeekToTimestamp(null); }, [timeIndex, imgLoaded, eventSequence, handleSetBox]);
}, [
seekToTimestamp,
event.start_time,
annotationOffset,
apiHost,
event.camera,
displaySource,
]);
const isWithinEventRange = const selectedLifecycle = useMemo(() => {
effectiveTime !== undefined && if (!eventSequence || eventSequence.length === 0) return undefined;
event.start_time !== undefined && const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
event.end_time !== undefined && return idx !== -1 ? eventSequence[idx] : eventSequence[0];
effectiveTime >= event.start_time && }, [eventSequence, timeIndex]);
effectiveTime <= event.end_time;
// Calculate how far down the blue line should extend based on effectiveTime const selectedIndex = useMemo(() => {
const calculateLineHeight = useCallback(() => { if (!eventSequence || eventSequence.length === 0) return 0;
if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) { const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
return 0; return idx === -1 ? 0 : idx;
} }, [eventSequence, timeIndex]);
const currentTime = effectiveTime ?? 0; // Calculate how far down the blue line should extend based on timeIndex
const calculateLineHeight = () => {
if (!eventSequence || eventSequence.length === 0) return 0;
const currentTime = timeIndex ?? 0;
// Find which events have been passed // Find which events have been passed
let lastPassedIndex = -1; let lastPassedIndex = -1;
@ -280,128 +412,43 @@ export function TrackingDetails({
100, 100,
lastPassedIndex * itemPercentage + interpolation * itemPercentage, lastPassedIndex * itemPercentage + interpolation * itemPercentage,
); );
}, [eventSequence, effectiveTime, isWithinEventRange]); };
const blueLineHeight = calculateLineHeight(); const blueLineHeight = calculateLineHeight();
const videoSource = useMemo(() => {
// event.start_time and event.end_time are in DETECT stream time
// Convert to record stream time, then create video clip with padding
const eventStartRecord = event.start_time + annotationOffset / 1000;
const eventEndRecord =
(event.end_time ?? Date.now() / 1000) + annotationOffset / 1000;
const startTime = eventStartRecord - REVIEW_PADDING;
const endTime = eventEndRecord + REVIEW_PADDING;
const playlist = `${baseUrl}vod/${event.camera}/start/${startTime}/end/${endTime}/index.m3u8`;
return {
playlist,
startPosition: 0,
};
}, [event, annotationOffset]);
// Determine camera aspect ratio category
const cameraAspect = useMemo(() => {
if (!aspectRatio) {
return "normal";
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
return "wide";
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
return "tall";
} else {
return "normal";
}
}, [aspectRatio]);
const handleSeekToTime = useCallback((timestamp: number, _play?: boolean) => {
// Set the target timestamp to seek to
setSeekToTimestamp(timestamp);
}, []);
const handleTimeUpdate = useCallback(
(time: number) => {
// event.start_time is detect stream time, convert to record
const eventStartRecord = event.start_time + annotationOffset / 1000;
const videoStartTime = eventStartRecord - REVIEW_PADDING;
const absoluteTime = time + videoStartTime;
setCurrentTime(absoluteTime);
},
[event.start_time, annotationOffset],
);
const [src, setSrc] = useState(
`${apiHost}api/${event.camera}/recordings/${currentTime + REVIEW_PADDING}/snapshot.jpg?height=500`,
);
const [hasError, setHasError] = useState(false);
// Derive the record timestamp to display: manualOverride if present,
// otherwise use currentTime.
const displayedRecordTime = manualOverride ?? currentTime;
useEffect(() => {
if (displayedRecordTime) {
const newSrc = `${apiHost}api/${event.camera}/recordings/${displayedRecordTime}/snapshot.jpg?height=500`;
setSrc(newSrc);
}
setImgLoaded(false);
setHasError(false);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedRecordTime]);
if (!config) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
return ( return (
<div <div className={className}>
className={cn(
isDesktop
? "flex size-full gap-4 overflow-hidden"
: "flex size-full flex-col gap-2",
className,
)}
>
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
{!fullscreen && (
<div className={cn("flex items-center gap-2")}>
<Button
className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => setPane("overview")}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
</div>
)}
<div <div
className={cn( className={cn(
"flex items-center justify-center", "relative mx-auto flex max-h-[50dvh] flex-row justify-center",
isDesktop && "overflow-hidden",
cameraAspect === "tall" ? "max-h-[50dvh] lg:max-h-[70dvh]" : "w-full",
cameraAspect === "tall" && isMobileOnly && "w-full",
cameraAspect !== "tall" && isDesktop && "flex-[3]",
)} )}
style={{ aspectRatio: aspectRatio }} style={{
ref={containerRef} aspectRatio: !imgLoaded ? aspectRatio : undefined,
}}
> >
<div
className={cn(
"relative",
cameraAspect === "tall" ? "h-full" : "w-full",
)}
>
{displaySource == "video" && (
<HlsVideoPlayer
videoRef={videoRef}
containerRef={containerRef}
visible={true}
currentSource={videoSource}
hotKeys={false}
supportsFullscreen={false}
fullscreen={false}
frigateControls={true}
onTimeUpdate={handleTimeUpdate}
onSeekToTime={handleSeekToTime}
isDetailMode={true}
camera={event.camera}
currentTimeOverride={currentTime}
/>
)}
{displaySource == "image" && (
<>
<ImageLoadingIndicator <ImageLoadingIndicator
className="absolute inset-0" className="absolute inset-0"
imgLoaded={imgLoaded} imgLoaded={imgLoaded}
@ -410,25 +457,18 @@ export function TrackingDetails({
<div className="relative aspect-video"> <div className="relative aspect-video">
<div className="flex flex-col items-center justify-center p-20 text-center"> <div className="flex flex-col items-center justify-center p-20 text-center">
<LuFolderX className="size-16" /> <LuFolderX className="size-16" />
{t("objectLifecycle.noImageFound")} {t("trackingDetails.noImageFound")}
</div> </div>
</div> </div>
)} )}
<div <div
className={cn("relative", imgLoaded ? "visible" : "invisible")} className={cn(
"relative inline-block",
imgLoaded ? "visible" : "invisible",
)}
> >
<div className="absolute z-50 size-full"> <ContextMenu>
<ObjectTrackOverlay <ContextMenuTrigger>
key={`overlay-${displayedRecordTime}`}
camera={event.camera}
showBoundingBoxes={true}
currentTime={displayedRecordTime}
videoWidth={imgRef?.current?.naturalWidth ?? 0}
videoHeight={imgRef?.current?.naturalHeight ?? 0}
className="absolute inset-0 z-10"
onSeekToTime={handleSeekToTime}
/>
</div>
<img <img
key={event.id} key={event.id}
ref={imgRef} ref={imgRef}
@ -449,68 +489,95 @@ export function TrackingDetails({
onLoad={() => setImgLoaded(true)} onLoad={() => setImgLoaded(true)}
onError={() => setHasError(true)} onError={() => setHasError(true)}
/> />
</div>
</> {showZones &&
)} imgRef.current?.width &&
imgRef.current?.height &&
lifecycleZones?.map((zone) => (
<div <div
className={cn( className="absolute inset-0 flex items-center justify-center"
"absolute top-2 z-[5] flex items-center gap-2", style={{
isIOS ? "right-8" : "right-2", width: imgRef.current?.clientWidth,
)} height: imgRef.current?.clientHeight,
>
{event && (
<Tooltip>
<TooltipTrigger>
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() => {
if (event?.id) {
const params = new URLSearchParams({
id: event.id,
}).toString();
navigate(`/review?${params}`);
}
}} }}
key={zone}
> >
<FaHistory className="size-4 text-white" /> <svg
</Chip> viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
</TooltipTrigger> className="absolute inset-0"
<TooltipPortal>
<TooltipContent>
{t("itemMenu.viewInHistory.label")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<a
download
href={`${baseUrl}api/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${(event.end_time ?? Date.now() / 1000) + REVIEW_PADDING}/clip.mp4`}
> >
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"> <polygon
<FaDownload className="size-4 text-white" /> points={getZonePolygon(zone)}
</Chip> className="fill-none stroke-2"
</a> style={{
</TooltipTrigger> stroke: `rgb(${getZoneColor(zone)?.join(",")})`,
<TooltipPortal> fill:
<TooltipContent> selectedZone == zone
{t("button.download", { ns: "common" })} ? `rgba(${getZoneColor(zone)?.join(",")}, 0.5)`
</TooltipContent> : `rgba(${getZoneColor(zone)?.join(",")}, 0.3)`,
</TooltipPortal> strokeWidth: selectedZone == zone ? 4 : 2,
</Tooltip> }}
/>
</svg>
</div> </div>
))}
{boxStyle && (
<div className="absolute border-2" style={boxStyle}>
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
</div>
)}
{attributeBoxStyle && (
<div className="absolute border-2" style={attributeBoxStyle} />
)}
{imgRef.current?.width &&
imgRef.current?.height &&
pathPoints &&
pathPoints.length > 0 && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key="path"
>
<svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
>
<ObjectPath
positions={pathPoints}
color={getObjectColor(event.label)}
width={2}
imgRef={imgRef}
onPointClick={handlePathPointClick}
/>
</svg>
</div>
)}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={() =>
navigate(
`/settings?page=masksAndZones&camera=${event.camera}&object_mask=${selectedLifecycle?.data.box}`,
)
}
>
<div className="text-primary">
{t("trackingDetails.createObjectMask")}
</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div> </div>
</div> </div>
<div className={cn(isDesktop && "flex-[2] overflow-hidden")}> <div className="mt-3 flex flex-row items-center justify-between">
{isDesktop && tabs && <div className="mb-4">{tabs}</div>}
<div
className={cn(
isDesktop && "scrollbar-container h-full overflow-y-auto",
)}
>
<div className="flex flex-row items-center justify-between">
<Heading as="h4">{t("trackingDetails.title")}</Heading> <Heading as="h4">{t("trackingDetails.title")}</Heading>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
@ -541,13 +608,12 @@ export function TrackingDetails({
</div> </div>
<div className="min-w-20 text-right text-sm text-muted-foreground"> <div className="min-w-20 text-right text-sm text-muted-foreground">
{t("trackingDetails.count", { {t("trackingDetails.count", {
first: eventSequence?.length ?? 0, first: selectedIndex + 1,
second: eventSequence?.length ?? 0, second: eventSequence?.length ?? 0,
})} })}
</div> </div>
</div> </div>
{config?.cameras[event.camera]?.onvif.autotracking {config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
.enabled_in_config && (
<div className="-mt-2 mb-2 text-sm text-danger"> <div className="-mt-2 mb-2 text-sm text-danger">
{t("trackingDetails.autoTrackingTips")} {t("trackingDetails.autoTrackingTips")}
</div> </div>
@ -558,14 +624,7 @@ export function TrackingDetails({
showZones={showZones} showZones={showZones}
setShowZones={setShowZones} setShowZones={setShowZones}
annotationOffset={annotationOffset} annotationOffset={annotationOffset}
setAnnotationOffset={(value) => { setAnnotationOffset={setAnnotationOffset}
if (typeof value === "function") {
const newValue = value(annotationOffset);
setAnnotationOffset(newValue);
} else {
setAnnotationOffset(value);
}
}}
/> />
)} )}
@ -580,10 +639,7 @@ export function TrackingDetails({
className="flex items-center gap-2 font-medium" className="flex items-center gap-2 font-medium"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
// event.start_time is detect time, convert to record setTimeIndex(event.start_time ?? 0);
handleSeekToTime(
(event.start_time ?? 0) + annotationOffset / 1000,
);
}} }}
role="button" role="button"
> >
@ -627,20 +683,16 @@ export function TrackingDetails({
{t("detail.noObjectDetailData", { ns: "views/events" })} {t("detail.noObjectDetailData", { ns: "views/events" })}
</div> </div>
) : ( ) : (
<div className="-pb-2 relative mx-0"> <div className="-pb-2 relative mx-2">
<div className="absolute -top-2 bottom-8 left-6 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" /> <div className="absolute -top-2 bottom-8 left-4 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
{isWithinEventRange && (
<div <div
className="absolute left-6 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300" className="absolute left-4 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
style={{ height: `${blueLineHeight}%` }} style={{ height: `${blueLineHeight}%` }}
/> />
)}
<div className="space-y-2"> <div className="space-y-2">
{eventSequence.map((item, idx) => { {eventSequence.map((item, idx) => {
const isActive = const isActive =
Math.abs( Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5;
(effectiveTime ?? 0) - (item.timestamp ?? 0),
) <= 0.5;
const formattedEventTimestamp = config const formattedEventTimestamp = config
? formatUnixTimestampToDateTime(item.timestamp ?? 0, { ? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
timezone: config.ui.timezone, timezone: config.ui.timezone,
@ -660,27 +712,23 @@ export function TrackingDetails({
: ""; : "";
const ratio = const ratio =
Array.isArray(item.data.box) && Array.isArray(item.data.box) && item.data.box.length >= 4
item.data.box.length >= 4
? ( ? (
aspectRatio * aspectRatio *
(item.data.box[2] / item.data.box[3]) (item.data.box[2] / item.data.box[3])
).toFixed(2) ).toFixed(2)
: "N/A"; : "N/A";
const areaPx = const areaPx =
Array.isArray(item.data.box) && Array.isArray(item.data.box) && item.data.box.length >= 4
item.data.box.length >= 4
? Math.round( ? Math.round(
(config.cameras[event.camera]?.detect?.width ?? (config.cameras[event.camera]?.detect?.width ?? 0) *
(config.cameras[event.camera]?.detect?.height ??
0) * 0) *
(config.cameras[event.camera]?.detect
?.height ?? 0) *
(item.data.box[2] * item.data.box[3]), (item.data.box[2] * item.data.box[3]),
) )
: undefined; : undefined;
const areaPct = const areaPct =
Array.isArray(item.data.box) && Array.isArray(item.data.box) && item.data.box.length >= 4
item.data.box.length >= 4
? (item.data.box[2] * item.data.box[3]).toFixed(4) ? (item.data.box[2] * item.data.box[3]).toFixed(4)
: undefined; : undefined;
@ -693,11 +741,17 @@ export function TrackingDetails({
ratio={ratio} ratio={ratio}
areaPx={areaPx} areaPx={areaPx}
areaPct={areaPct} areaPct={areaPct}
onClick={() => handleLifecycleClick(item)} onClick={() => {
setTimeIndex(item.timestamp ?? 0);
handleSetBox(
item.data.box ?? [],
item.data.attribute_box,
);
setLifecycleZones(item.data.zones);
setSelectedZone("");
}}
setSelectedZone={setSelectedZone} setSelectedZone={setSelectedZone}
getZoneColor={getZoneColor} getZoneColor={getZoneColor}
effectiveTime={effectiveTime}
isTimelineActive={isWithinEventRange}
/> />
); );
})} })}
@ -708,11 +762,47 @@ export function TrackingDetails({
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
); );
} }
type GetTimelineIconParams = {
lifecycleItem: TrackingDetailsSequence;
className?: string;
};
export function LifecycleIcon({
lifecycleItem,
className,
}: GetTimelineIconParams) {
switch (lifecycleItem.class_type) {
case "visible":
return <LuPlay className={cn(className)} />;
case "gone":
return <IoMdExit className={cn(className)} />;
case "active":
return <IoPlayCircleOutline className={cn(className)} />;
case "stationary":
return <LuCircle className={cn(className)} />;
case "entered_zone":
return <MdOutlineLocationOn className={cn(className)} />;
case "attribute":
switch (lifecycleItem.data?.attribute) {
case "face":
return <MdFaceUnlock className={cn(className)} />;
case "license_plate":
return <MdOutlinePictureInPictureAlt className={cn(className)} />;
default:
return <LuTruck className={cn(className)} />;
}
case "heard":
return <LuEar className={cn(className)} />;
case "external":
return <LuCircleDot className={cn(className)} />;
default:
return null;
}
}
type LifecycleIconRowProps = { type LifecycleIconRowProps = {
item: TrackingDetailsSequence; item: TrackingDetailsSequence;
isActive?: boolean; isActive?: boolean;
@ -723,8 +813,6 @@ type LifecycleIconRowProps = {
onClick: () => void; onClick: () => void;
setSelectedZone: (z: string) => void; setSelectedZone: (z: string) => void;
getZoneColor: (zoneName: string) => number[] | undefined; getZoneColor: (zoneName: string) => number[] | undefined;
effectiveTime?: number;
isTimelineActive?: boolean;
}; };
function LifecycleIconRow({ function LifecycleIconRow({
@ -737,8 +825,6 @@ function LifecycleIconRow({
onClick, onClick,
setSelectedZone, setSelectedZone,
getZoneColor, getZoneColor,
effectiveTime,
isTimelineActive,
}: LifecycleIconRowProps) { }: LifecycleIconRowProps) {
const { t } = useTranslation(["views/explore", "components/player"]); const { t } = useTranslation(["views/explore", "components/player"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -751,19 +837,17 @@ function LifecycleIconRow({
role="button" role="button"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"rounded-md p-2 pr-0 text-sm text-primary-variant", "rounded-md p-2 text-sm text-primary-variant",
isActive && "bg-secondary-highlight font-semibold text-primary", isActive && "bg-secondary-highlight font-semibold text-primary",
!isActive && "duration-500", !isActive && "duration-500",
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative ml-2 flex size-4 items-center justify-center"> <div className="relative flex size-4 items-center justify-center">
<LuCircle <LuCircle
className={cn( className={cn(
"relative z-10 size-2.5 fill-secondary-foreground stroke-none", "relative z-10 ml-[1px] size-2.5 fill-secondary-foreground stroke-none",
(isActive || (effectiveTime ?? 0) >= (item?.timestamp ?? 0)) && isActive && "fill-selected duration-300",
isTimelineActive &&
"fill-selected duration-300",
)} )}
/> />
</div> </div>

View File

@ -289,7 +289,6 @@ export default function VideoControls({
}} }}
onUploadFrame={onUploadFrame} onUploadFrame={onUploadFrame}
containerRef={containerRef} containerRef={containerRef}
fullscreen={fullscreen}
/> />
)} )}
{features.fullscreen && toggleFullscreen && ( {features.fullscreen && toggleFullscreen && (
@ -307,7 +306,6 @@ type FrigatePlusUploadButtonProps = {
onClose: () => void; onClose: () => void;
onUploadFrame: () => void; onUploadFrame: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>; containerRef?: React.MutableRefObject<HTMLDivElement | null>;
fullscreen?: boolean;
}; };
function FrigatePlusUploadButton({ function FrigatePlusUploadButton({
video, video,
@ -315,7 +313,6 @@ function FrigatePlusUploadButton({
onClose, onClose,
onUploadFrame, onUploadFrame,
containerRef, containerRef,
fullscreen,
}: FrigatePlusUploadButtonProps) { }: FrigatePlusUploadButtonProps) {
const { t } = useTranslation(["components/player"]); const { t } = useTranslation(["components/player"]);
@ -352,11 +349,7 @@ function FrigatePlusUploadButton({
/> />
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent <AlertDialogContent
portalProps={ portalProps={{ container: containerRef?.current }}
fullscreen && containerRef?.current
? { container: containerRef.current }
: undefined
}
className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl" className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl"
> >
<AlertDialogHeader> <AlertDialogHeader>

View File

@ -174,7 +174,9 @@ export default function CameraWizardDialog({
...(friendlyName && { friendly_name: friendlyName }), ...(friendlyName && { friendly_name: friendlyName }),
ffmpeg: { ffmpeg: {
inputs: wizardData.streams.map((stream, index) => { inputs: wizardData.streams.map((stream, index) => {
if (stream.restream) { const isRestreamed =
wizardData.restreamIds?.includes(stream.id) ?? false;
if (isRestreamed) {
const go2rtcStreamName = const go2rtcStreamName =
wizardData.streams!.length === 1 wizardData.streams!.length === 1
? finalCameraName ? finalCameraName
@ -232,11 +234,7 @@ export default function CameraWizardDialog({
wizardData.streams!.length === 1 wizardData.streams!.length === 1
? finalCameraName ? finalCameraName
: `${finalCameraName}_${index + 1}`; : `${finalCameraName}_${index + 1}`;
go2rtcStreams[streamName] = [stream.url];
const streamUrl = stream.useFfmpeg
? `ffmpeg:${stream.url}`
: stream.url;
go2rtcStreams[streamName] = [streamUrl];
}); });
if (Object.keys(go2rtcStreams).length > 0) { if (Object.keys(go2rtcStreams).length > 0) {

View File

@ -385,7 +385,7 @@ export default function Step1NameCamera({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-md h-8" className="h-8"
placeholder={t( placeholder={t(
"cameraWizard.step1.cameraNamePlaceholder", "cameraWizard.step1.cameraNamePlaceholder",
)} )}
@ -475,7 +475,7 @@ export default function Step1NameCamera({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-md h-8" className="h-8"
placeholder="192.168.1.100" placeholder="192.168.1.100"
{...field} {...field}
/> />
@ -495,7 +495,7 @@ export default function Step1NameCamera({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-md h-8" className="h-8"
placeholder={t( placeholder={t(
"cameraWizard.step1.usernamePlaceholder", "cameraWizard.step1.usernamePlaceholder",
)} )}
@ -518,7 +518,7 @@ export default function Step1NameCamera({
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<Input <Input
className="text-md h-8 pr-10" className="h-8 pr-10"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder={t( placeholder={t(
"cameraWizard.step1.passwordPlaceholder", "cameraWizard.step1.passwordPlaceholder",
@ -558,7 +558,7 @@ export default function Step1NameCamera({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
className="text-md h-8" className="h-8"
placeholder="rtsp://username:password@host:port/path" placeholder="rtsp://username:password@host:port/path"
{...field} {...field}
/> />
@ -608,12 +608,6 @@ export default function Step1NameCamera({
</div> </div>
)} )}
{isTesting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ActivityIndicator className="size-4" />
{testStatus}
</div>
)}
<div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4"> <div className="flex flex-col gap-3 pt-3 sm:flex-row sm:justify-end sm:gap-4">
<Button <Button
type="button" type="button"
@ -641,7 +635,10 @@ export default function Step1NameCamera({
variant="select" variant="select"
className="flex items-center justify-center gap-2 sm:flex-1" className="flex items-center justify-center gap-2 sm:flex-1"
> >
{t("cameraWizard.step1.testConnection")} {isTesting && <ActivityIndicator className="size-4" />}
{isTesting && testStatus
? testStatus
: t("cameraWizard.step1.testConnection")}
</Button> </Button>
)} )}
</div> </div>

View File

@ -201,12 +201,16 @@ export default function Step2StreamConfig({
const setRestream = useCallback( const setRestream = useCallback(
(streamId: string) => { (streamId: string) => {
const stream = streams.find((s) => s.id === streamId); const currentIds = wizardData.restreamIds || [];
if (!stream) return; const isSelected = currentIds.includes(streamId);
const newIds = isSelected
updateStream(streamId, { restream: !stream.restream }); ? currentIds.filter((id) => id !== streamId)
: [...currentIds, streamId];
onUpdate({
restreamIds: newIds,
});
}, },
[streams, updateStream], [wizardData.restreamIds, onUpdate],
); );
const hasDetectRole = streams.some((s) => s.roles.includes("detect")); const hasDetectRole = streams.some((s) => s.roles.includes("detect"));
@ -431,7 +435,9 @@ export default function Step2StreamConfig({
{t("cameraWizard.step2.go2rtc")} {t("cameraWizard.step2.go2rtc")}
</span> </span>
<Switch <Switch
checked={stream.restream || false} checked={(wizardData.restreamIds || []).includes(
stream.id,
)}
onCheckedChange={() => setRestream(stream.id)} onCheckedChange={() => setRestream(stream.id)}
/> />
</div> </div>

View File

@ -1,13 +1,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuRotateCcw, LuInfo } from "react-icons/lu"; import { LuRotateCcw } from "react-icons/lu";
import { useState, useCallback, useMemo, useEffect } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import axios from "axios"; import axios from "axios";
@ -222,6 +216,7 @@ export default function Step3Validation({
brandTemplate: wizardData.brandTemplate, brandTemplate: wizardData.brandTemplate,
customUrl: wizardData.customUrl, customUrl: wizardData.customUrl,
streams: wizardData.streams, streams: wizardData.streams,
restreamIds: wizardData.restreamIds,
}; };
onSave(configData); onSave(configData);
@ -327,51 +322,6 @@ export default function Step3Validation({
</div> </div>
)} )}
{result?.success && (
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm">
{t("cameraWizard.step3.ffmpegModule")}
</span>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0"
>
<LuInfo className="size-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="pointer-events-auto w-80 text-xs">
<div className="space-y-2">
<div className="font-medium">
{t("cameraWizard.step3.ffmpegModule")}
</div>
<div className="text-muted-foreground">
{t(
"cameraWizard.step3.ffmpegModuleDescription",
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
<Switch
checked={stream.useFfmpeg || false}
onCheckedChange={(checked) => {
onUpdate({
streams: streams.map((s) =>
s.id === stream.id
? { ...s, useFfmpeg: checked }
: s,
),
});
}}
/>
</div>
)}
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center"> <div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
<span className="break-all text-sm text-muted-foreground"> <span className="break-all text-sm text-muted-foreground">
{stream.url} {stream.url}
@ -541,7 +491,8 @@ function StreamIssues({
// Restreaming check // Restreaming check
if (stream.roles.includes("record")) { if (stream.roles.includes("record")) {
if (stream.restream) { const restreamIds = wizardData.restreamIds || [];
if (restreamIds.includes(stream.id)) {
result.push({ result.push({
type: "warning", type: "warning",
message: t("cameraWizard.step3.issues.restreamingWarning"), message: t("cameraWizard.step3.issues.restreamingWarning"),
@ -709,10 +660,9 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
useEffect(() => { useEffect(() => {
// Register stream with go2rtc // Register stream with go2rtc
const streamUrl = stream.useFfmpeg ? `ffmpeg:${stream.url}` : stream.url;
axios axios
.put(`go2rtc/streams/${streamId}`, null, { .put(`go2rtc/streams/${streamId}`, null, {
params: { src: streamUrl }, params: { src: stream.url },
}) })
.then(() => { .then(() => {
// Add small delay to allow go2rtc api to run and initialize the stream // Add small delay to allow go2rtc api to run and initialize the stream
@ -730,7 +680,7 @@ function StreamPreview({ stream, onBandwidthUpdate }: StreamPreviewProps) {
// do nothing on cleanup errors - go2rtc won't consume the streams // do nothing on cleanup errors - go2rtc won't consume the streams
}); });
}; };
}, [stream.url, stream.useFfmpeg, streamId]); }, [stream.url, streamId]);
const resolution = stream.testResult?.resolution; const resolution = stream.testResult?.resolution;
let aspectRatio = "16/9"; let aspectRatio = "16/9";

View File

@ -22,7 +22,6 @@ import {
LuChevronRight, LuChevronRight,
LuSettings, LuSettings,
} from "react-icons/lu"; } from "react-icons/lu";
import { MdAutoAwesome } from "react-icons/md";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import EventMenu from "@/components/timeline/EventMenu"; import EventMenu from "@/components/timeline/EventMenu";
import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog"; import { FrigatePlusDialog } from "@/components/overlay/dialog/FrigatePlusDialog";
@ -58,7 +57,7 @@ export default function DetailStream({
elementRef: scrollRef, elementRef: scrollRef,
}); });
const effectiveTime = currentTime - annotationOffset / 1000; const effectiveTime = currentTime + annotationOffset / 1000;
const [upload, setUpload] = useState<Event | undefined>(undefined); const [upload, setUpload] = useState<Event | undefined>(undefined);
const [controlsExpanded, setControlsExpanded] = useState(false); const [controlsExpanded, setControlsExpanded] = useState(false);
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence( const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
@ -214,7 +213,6 @@ export default function DetailStream({
config={config} config={config}
onSeek={onSeekCheckPlaying} onSeek={onSeekCheckPlaying}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
isActive={activeReviewId == id} isActive={activeReviewId == id}
onActivate={() => setActiveReviewId(id)} onActivate={() => setActiveReviewId(id)}
onOpenUpload={(e) => setUpload(e)} onOpenUpload={(e) => setUpload(e)}
@ -280,7 +278,6 @@ type ReviewGroupProps = {
onActivate?: () => void; onActivate?: () => void;
onOpenUpload?: (e: Event) => void; onOpenUpload?: (e: Event) => void;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
alwaysExpandActive?: boolean; alwaysExpandActive?: boolean;
}; };
@ -293,14 +290,11 @@ function ReviewGroup({
onActivate, onActivate,
onOpenUpload, onOpenUpload,
effectiveTime, effectiveTime,
annotationOffset,
alwaysExpandActive = false, alwaysExpandActive = false,
}: ReviewGroupProps) { }: ReviewGroupProps) {
const { t } = useTranslation("views/events"); const { t } = useTranslation("views/events");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const start = review.start_time ?? 0; const start = review.start_time ?? 0;
// review.start_time is in detect time, convert to record for seeking
const startRecord = start + annotationOffset / 1000;
// Auto-expand when this review becomes active and alwaysExpandActive is enabled // Auto-expand when this review becomes active and alwaysExpandActive is enabled
useEffect(() => { useEffect(() => {
@ -368,11 +362,7 @@ function ReviewGroup({
return ( return (
<div <div
data-review-id={id} data-review-id={id}
className={`mx-1 cursor-pointer rounded-lg bg-secondary px-0 py-3 outline outline-[2px] -outline-offset-[1.8px] ${ className="cursor-pointer rounded-lg bg-secondary py-3"
isActive
? "shadow-selected outline-selected"
: "outline-transparent duration-500"
}`}
> >
<div <div
className={cn( className={cn(
@ -381,16 +371,16 @@ function ReviewGroup({
)} )}
onClick={() => { onClick={() => {
onActivate?.(); onActivate?.();
onSeek(startRecord); onSeek(start);
}} }}
> >
<div className="ml-4 mr-2 mt-1.5 flex flex-row items-start"> <div className="ml-4 mr-2 mt-1.5 flex flex-row items-start">
<LuCircle <LuCircle
className={cn( className={cn(
"size-3 duration-500", "size-3",
review.severity == "alert" isActive
? "fill-severity_alert text-severity_alert" ? "fill-selected text-selected"
: "fill-severity_detection text-severity_detection", : "fill-muted duration-500 dark:fill-secondary-highlight dark:text-secondary-highlight",
)} )}
/> />
</div> </div>
@ -411,9 +401,8 @@ function ReviewGroup({
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{review.data.metadata?.title && ( {review.data.metadata?.title && (
<div className="mb-1 flex items-center gap-1 text-sm text-primary-variant"> <div className="mb-1 text-sm text-primary-variant">
<MdAutoAwesome className="size-3 shrink-0" /> {review.data.metadata.title}
<span className="truncate">{review.data.metadata.title}</span>
</div> </div>
)} )}
<div className="flex flex-row items-center gap-1.5"> <div className="flex flex-row items-center gap-1.5">
@ -460,9 +449,7 @@ function ReviewGroup({
<EventList <EventList
key={event.id} key={event.id}
event={event} event={event}
review={review}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
onSeek={onSeek} onSeek={onSeek}
onOpenUpload={onOpenUpload} onOpenUpload={onOpenUpload}
/> />
@ -495,17 +482,13 @@ function ReviewGroup({
type EventListProps = { type EventListProps = {
event: Event; event: Event;
review: ReviewSegment;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
onSeek: (ts: number, play?: boolean) => void; onSeek: (ts: number, play?: boolean) => void;
onOpenUpload?: (e: Event) => void; onOpenUpload?: (e: Event) => void;
}; };
function EventList({ function EventList({
event, event,
review,
effectiveTime, effectiveTime,
annotationOffset,
onSeek, onSeek,
onOpenUpload, onOpenUpload,
}: EventListProps) { }: EventListProps) {
@ -522,17 +505,14 @@ function EventList({
if (event) { if (event) {
setSelectedObjectIds([]); setSelectedObjectIds([]);
setSelectedObjectIds([event.id]); setSelectedObjectIds([event.id]);
// event.start_time is detect time, convert to record onSeek(event.start_time);
const recordTime = event.start_time + annotationOffset / 1000;
onSeek(recordTime);
} else { } else {
setSelectedObjectIds([]); setSelectedObjectIds([]);
} }
}; };
const handleTimelineClick = (ts: number, play?: boolean) => { const handleTimelineClick = (ts: number, play?: boolean) => {
setSelectedObjectIds([]); handleObjectSelect(event);
setSelectedObjectIds([event.id]);
onSeek(ts, play); onSeek(ts, play);
}; };
@ -574,6 +554,7 @@ function EventList({
)} )}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSeek(event.start_time);
handleObjectSelect(event); handleObjectSelect(event);
}} }}
role="button" role="button"
@ -587,6 +568,7 @@ function EventList({
className="flex flex-1 items-center gap-2" className="flex flex-1 items-center gap-2"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onSeek(event.start_time);
handleObjectSelect(event); handleObjectSelect(event);
}} }}
role="button" role="button"
@ -622,11 +604,9 @@ function EventList({
<div className="mt-2"> <div className="mt-2">
<ObjectTimeline <ObjectTimeline
review={review}
eventId={event.id} eventId={event.id}
onSeek={handleTimelineClick} onSeek={handleTimelineClick}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
startTime={event.start_time} startTime={event.start_time}
endTime={event.end_time} endTime={event.end_time}
/> />
@ -641,7 +621,6 @@ type LifecycleItemProps = {
isActive?: boolean; isActive?: boolean;
onSeek?: (timestamp: number, play?: boolean) => void; onSeek?: (timestamp: number, play?: boolean) => void;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
isTimelineActive?: boolean; isTimelineActive?: boolean;
}; };
@ -650,7 +629,6 @@ function LifecycleItem({
isActive, isActive,
onSeek, onSeek,
effectiveTime, effectiveTime,
annotationOffset,
isTimelineActive = false, isTimelineActive = false,
}: LifecycleItemProps) { }: LifecycleItemProps) {
const { t } = useTranslation("views/events"); const { t } = useTranslation("views/events");
@ -704,8 +682,7 @@ function LifecycleItem({
<div <div
role="button" role="button"
onClick={() => { onClick={() => {
const recordTimestamp = item.timestamp + annotationOffset / 1000; onSeek?.(item.timestamp, false);
onSeek?.(recordTimestamp, false);
}} }}
className={cn( className={cn(
"flex cursor-pointer items-center gap-2 text-sm text-primary-variant", "flex cursor-pointer items-center gap-2 text-sm text-primary-variant",
@ -771,44 +748,26 @@ function LifecycleItem({
// Fetch and render timeline entries for a single event id on demand. // Fetch and render timeline entries for a single event id on demand.
function ObjectTimeline({ function ObjectTimeline({
review,
eventId, eventId,
onSeek, onSeek,
effectiveTime, effectiveTime,
annotationOffset,
startTime, startTime,
endTime, endTime,
}: { }: {
review: ReviewSegment;
eventId: string; eventId: string;
onSeek: (ts: number, play?: boolean) => void; onSeek: (ts: number, play?: boolean) => void;
effectiveTime?: number; effectiveTime?: number;
annotationOffset: number;
startTime?: number; startTime?: number;
endTime?: number; endTime?: number;
}) { }) {
const { t } = useTranslation("views/events"); const { t } = useTranslation("views/events");
const { data: fullTimeline, isValidating } = useSWR< const { data: timeline, isValidating } = useSWR<TrackingDetailsSequence[]>([
TrackingDetailsSequence[]
>([
"timeline", "timeline",
{ {
source_id: eventId, 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)) { if (isValidating && (!timeline || timeline.length === 0)) {
return <ActivityIndicator className="ml-2 size-3" />; return <ActivityIndicator className="ml-2 size-3" />;
} }
@ -898,7 +857,6 @@ function ObjectTimeline({
onSeek={onSeek} onSeek={onSeek}
isActive={isActive} isActive={isActive}
effectiveTime={effectiveTime} effectiveTime={effectiveTime}
annotationOffset={annotationOffset}
isTimelineActive={isWithinEventRange} isTimelineActive={isWithinEventRange}
/> />
); );

View File

@ -1,3 +1,4 @@
import { useApiHost } from "@/api";
import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewSegment, ReviewSeverity } from "@/types/review";
@ -17,7 +18,6 @@ import { HoverCardPortal } from "@radix-ui/react-hover-card";
import scrollIntoView from "scroll-into-view-if-needed"; import scrollIntoView from "scroll-into-view-if-needed";
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata"; import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
import useTapUtils from "@/hooks/use-tap-utils"; import useTapUtils from "@/hooks/use-tap-utils";
import ReviewCard from "../card/ReviewCard";
type EventSegmentProps = { type EventSegmentProps = {
events: ReviewSegment[]; events: ReviewSegment[];
@ -54,7 +54,7 @@ export function EventSegment({
displaySeverityType, displaySeverityType,
shouldShowRoundedCorners, shouldShowRoundedCorners,
getEventStart, getEventStart,
getEvent, getEventThumbnail,
} = useEventSegmentUtils(segmentDuration, events, severityType); } = useEventSegmentUtils(segmentDuration, events, severityType);
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils( const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
@ -87,11 +87,13 @@ export function EventSegment({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [getEventStart, segmentTime]); }, [getEventStart, segmentTime]);
const apiHost = useApiHost();
const { handleTouchStart } = useTapUtils(); const { handleTouchStart } = useTapUtils();
const segmentEvent = useMemo(() => { const eventThumbnail = useMemo(() => {
return getEvent(segmentTime); return getEventThumbnail(segmentTime);
}, [getEvent, segmentTime]); }, [getEventThumbnail, segmentTime]);
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]); const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
const segmentKey = useMemo( const segmentKey = useMemo(
@ -250,7 +252,10 @@ export function EventSegment({
className="w-[250px] rounded-lg p-2 md:rounded-2xl" className="w-[250px] rounded-lg p-2 md:rounded-2xl"
side="left" side="left"
> >
{segmentEvent && <ReviewCard event={segmentEvent} />} <img
className="rounded-lg"
src={`${apiHost}${eventThumbnail.replace("/media/frigate/", "")}`}
/>
</HoverCardContent> </HoverCardContent>
</HoverCardPortal> </HoverCardPortal>
</HoverCard> </HoverCard>

View File

@ -101,7 +101,7 @@ export default function Step1NameAndType({
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
mode: "onBlur", mode: "onChange",
defaultValues: { defaultValues: {
enabled: true, enabled: true,
name: initialData?.name ?? trigger?.name ?? "", name: initialData?.name ?? trigger?.name ?? "",

View File

@ -22,7 +22,6 @@ interface DetailStreamProviderProps {
isDetailMode: boolean; isDetailMode: boolean;
currentTime: number; currentTime: number;
camera: string; camera: string;
initialSelectedObjectIds?: string[];
} }
export function DetailStreamProvider({ export function DetailStreamProvider({
@ -30,11 +29,8 @@ export function DetailStreamProvider({
isDetailMode, isDetailMode,
currentTime, currentTime,
camera, camera,
initialSelectedObjectIds,
}: DetailStreamProviderProps) { }: DetailStreamProviderProps) {
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>( const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>([]);
() => initialSelectedObjectIds ?? [],
);
const toggleObjectSelection = (id: string | undefined) => { const toggleObjectSelection = (id: string | undefined) => {
if (id === undefined) { if (id === undefined) {

View File

@ -191,8 +191,8 @@ export const useEventSegmentUtils = (
[events, getSegmentStart, getSegmentEnd, severityType], [events, getSegmentStart, getSegmentEnd, severityType],
); );
const getEvent = useCallback( const getEventThumbnail = useCallback(
(time: number): ReviewSegment | undefined => { (time: number): string => {
const matchingEvent = events.find((event) => { const matchingEvent = events.find((event) => {
return ( return (
time >= getSegmentStart(event.start_time) && time >= getSegmentStart(event.start_time) &&
@ -201,7 +201,7 @@ export const useEventSegmentUtils = (
); );
}); });
return matchingEvent; return matchingEvent?.thumb_path ?? "";
}, },
[events, getSegmentStart, getSegmentEnd, severityType], [events, getSegmentStart, getSegmentEnd, severityType],
); );
@ -214,6 +214,6 @@ export const useEventSegmentUtils = (
getReviewed, getReviewed,
shouldShowRoundedCorners, shouldShowRoundedCorners,
getEventStart, getEventStart,
getEvent, getEventThumbnail,
}; };
}; };

View File

@ -845,7 +845,6 @@ function FaceAttemptGroup({
selectedItems={selectedFaces} selectedItems={selectedFaces}
i18nLibrary="views/faceLibrary" i18nLibrary="views/faceLibrary"
objectType="person" objectType="person"
noClassificationLabel="details.unknown"
onClick={(data) => { onClick={(data) => {
if (data) { if (data) {
onClickFaces([data.filename], true); onClickFaces([data.filename], true);

View File

@ -157,11 +157,9 @@ function MobileMenuItem({
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
return ( return (
<div <Button
className={cn( variant="ghost"
"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={cn("w-full justify-between pr-2", className)}
className,
)}
onClick={() => { onClick={() => {
onSelect(item.key); onSelect(item.key);
onClose?.(); onClose?.();
@ -169,7 +167,7 @@ function MobileMenuItem({
> >
<div className="smart-capitalize">{t("menu." + item.key)}</div> <div className="smart-capitalize">{t("menu." + item.key)}</div>
<LuChevronRight className="size-4" /> <LuChevronRight className="size-4" />
</div> </Button>
); );
} }
@ -275,9 +273,6 @@ export default function Settings() {
} else { } else {
setPageToggle(page as SettingsType); setPageToggle(page as SettingsType);
} }
if (isMobile) {
setContentMobileOpen(true);
}
} }
// don't clear url params if we're creating a new object mask // don't clear url params if we're creating a new object mask
return !(searchParams.has("object_mask") || searchParams.has("event_id")); return !(searchParams.has("object_mask") || searchParams.has("event_id"));
@ -287,9 +282,6 @@ export default function Settings() {
const cameraNames = cameras.map((c) => c.name); const cameraNames = cameras.map((c) => c.name);
if (cameraNames.includes(camera)) { if (cameraNames.includes(camera)) {
setSelectedCamera(camera); setSelectedCamera(camera);
if (isMobile) {
setContentMobileOpen(true);
}
} }
// don't clear url params if we're creating a new object mask or trigger // don't clear url params if we're creating a new object mask or trigger
return !(searchParams.has("object_mask") || searchParams.has("event_id")); return !(searchParams.has("object_mask") || searchParams.has("event_id"));

View File

@ -85,8 +85,6 @@ export type StreamConfig = {
quality?: string; quality?: string;
testResult?: TestResult; testResult?: TestResult;
userTested?: boolean; userTested?: boolean;
useFfmpeg?: boolean;
restream?: boolean;
}; };
export type TestResult = { export type TestResult = {
@ -107,6 +105,7 @@ export type WizardFormData = {
brandTemplate?: CameraBrand; brandTemplate?: CameraBrand;
customUrl?: string; customUrl?: string;
streams?: StreamConfig[]; streams?: StreamConfig[];
restreamIds?: string[];
}; };
// API Response Types // API Response Types
@ -147,7 +146,6 @@ export type CameraConfigData = {
inputs: { inputs: {
path: string; path: string;
roles: string[]; roles: string[];
input_args?: string;
}[]; }[];
}; };
live?: { live?: {

View File

@ -306,7 +306,6 @@ export type CustomClassificationModelConfig = {
threshold: number; threshold: number;
object_config?: { object_config?: {
objects: string[]; objects: string[];
classification_type: string;
}; };
state_config?: { state_config?: {
cameras: { cameras: {

View File

@ -13,8 +13,7 @@ function formatZonesList(zones: string[]): string {
}); });
} }
const separatorWithSpace = t("list.separatorWithSpace", { ns: "common" }); const allButLast = zones.slice(0, -1).join(", ");
const allButLast = zones.slice(0, -1).join(separatorWithSpace);
return t("list.many", { return t("list.many", {
items: allButLast, items: allButLast,
last: zones[zones.length - 1], last: zones[zones.length - 1],

View File

@ -43,5 +43,5 @@ export function generateFixedHash(name: string, prefix: string = "id"): string {
* @returns True if the name is valid, false otherwise * @returns True if the name is valid, false otherwise
*/ */
export function isValidId(name: string): boolean { export function isValidId(name: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(name) && !/^\d+$/.test(name); return /^[a-zA-Z0-9_-]+$/.test(name);
} }

View File

@ -1,9 +1,8 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog"; import ClassificationModelWizardDialog from "@/components/classification/ClassificationModelWizardDialog";
import ClassificationModelEditDialog from "@/components/classification/ClassificationModelEditDialog";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay"; import { ImageShadowOverlay } from "@/components/overlay/ImageShadowOverlay";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import useOptimisticState from "@/hooks/use-optimistic-state"; import useOptimisticState from "@/hooks/use-optimistic-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -11,34 +10,13 @@ import {
CustomClassificationModelConfig, CustomClassificationModelConfig,
FrigateConfig, FrigateConfig,
} from "@/types/frigateConfig"; } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaFolderPlus } from "react-icons/fa"; import { FaFolderPlus } from "react-icons/fa";
import { MdModelTraining } from "react-icons/md"; import { MdModelTraining } from "react-icons/md";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import { FiMoreVertical } from "react-icons/fi";
import useSWR from "swr"; import useSWR from "swr";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { useOverlayState } from "@/hooks/use-overlay-state"; import { useOverlayState } from "@/hooks/use-overlay-state";
import axios from "axios";
import { toast } from "sonner";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import BlurredIconButton from "@/components/button/BlurredIconButton";
const allModelTypes = ["objects", "states"] as const; const allModelTypes = ["objects", "states"] as const;
type ModelType = (typeof allModelTypes)[number]; type ModelType = (typeof allModelTypes)[number];
@ -148,7 +126,7 @@ export default function ModelSelectionView({
onClick={() => setNewModel(true)} onClick={() => setNewModel(true)}
> >
<FaFolderPlus /> <FaFolderPlus />
{t("button.addClassification")} Add Classification
</Button> </Button>
</div> </div>
</div> </div>
@ -164,8 +142,6 @@ export default function ModelSelectionView({
key={config.name} key={config.name}
config={config} config={config}
onClick={() => onClick(config)} onClick={() => onClick(config)}
onUpdate={() => refreshConfig()}
onDelete={() => refreshConfig()}
/> />
))} ))}
</div> </div>
@ -203,62 +179,12 @@ function NoModelsView({
type ModelCardProps = { type ModelCardProps = {
config: CustomClassificationModelConfig; config: CustomClassificationModelConfig;
onClick: () => void; onClick: () => void;
onUpdate: () => void;
onDelete: () => void;
}; };
function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) { function ModelCard({ config, onClick }: ModelCardProps) {
const { t } = useTranslation(["views/classificationModel"]);
const { data: dataset } = useSWR<{ const { data: dataset } = useSWR<{
[id: string]: string[]; [id: string]: string[];
}>(`classification/${config.name}/dataset`, { revalidateOnFocus: false }); }>(`classification/${config.name}/dataset`, { revalidateOnFocus: false });
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const handleDelete = useCallback(async () => {
try {
await axios.delete(`classification/${config.name}`);
await axios.put("/config/set", {
requires_restart: 0,
update_topic: `config/classification/custom/${config.name}`,
config_data: {
classification: {
custom: {
[config.name]: "",
},
},
},
});
toast.success(t("toast.success.deletedModel", { count: 1 }), {
position: "top-center",
});
onDelete();
} 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.deleteModelFailed", { errorMessage }), {
position: "top-center",
});
}
}, [config, onDelete, t]);
const handleDeleteClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setDeleteDialogOpen(true);
}, []);
const handleEditClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setEditDialogOpen(true);
}, []);
const coverImage = useMemo(() => { const coverImage = useMemo(() => {
if (!dataset) { if (!dataset) {
return undefined; return undefined;
@ -278,76 +204,22 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
}, [dataset]); }, [dataset]);
return ( return (
<>
<ClassificationModelEditDialog
open={editDialogOpen}
model={config}
onClose={() => setEditDialogOpen(false)}
onSuccess={() => onUpdate()}
/>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteModel.title")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{t("deleteModel.single", { name: config.name })}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleDelete}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div <div
key={config.name}
className={cn( className={cn(
"relative aspect-square w-full cursor-pointer overflow-hidden rounded-lg", "relative aspect-square w-full cursor-pointer overflow-hidden rounded-lg",
"outline-transparent duration-500",
)} )}
onClick={onClick} onClick={() => onClick()}
> >
<img <img
className="size-full" className="size-full"
src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`} src={`${baseUrl}clips/${config.name}/dataset/${coverImage?.name}/${coverImage?.img}`}
/> />
<ImageShadowOverlay lowerClassName="h-[30%] z-0" /> <ImageShadowOverlay />
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize"> <div className="absolute bottom-2 left-3 text-lg smart-capitalize">
{config.name} {config.name}
</div> </div>
<div className="absolute bottom-2 right-2 z-40">
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<BlurredIconButton>
<FiMoreVertical className="size-5 text-white" />
</BlurredIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
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>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div>
</>
); );
} }

View File

@ -327,7 +327,6 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
</AlertDialog> </AlertDialog>
<div className="flex flex-row justify-between gap-2 p-2 align-middle"> <div className="flex flex-row justify-between gap-2 p-2 align-middle">
{(isDesktop || !selectedImages?.length) && (
<div className="flex flex-row items-center justify-center gap-2"> <div className="flex flex-row items-center justify-center gap-2">
<Button <Button
className="flex items-center gap-2.5 rounded-lg" className="flex items-center gap-2.5 rounded-lg"
@ -341,7 +340,6 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
</div> </div>
)} )}
</Button> </Button>
<LibrarySelector <LibrarySelector
pageToggle={pageToggle} pageToggle={pageToggle}
dataset={dataset || {}} dataset={dataset || {}}
@ -351,15 +349,9 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
onRename={() => {}} onRename={() => {}}
/> />
</div> </div>
)}
{selectedImages?.length > 0 ? ( {selectedImages?.length > 0 ? (
<div <div className="flex items-center justify-center gap-2">
className={cn( <div className="mx-1 flex w-48 items-center justify-center text-sm text-muted-foreground">
"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">{`${selectedImages.length} selected`}</div>
<div className="p-1">{"|"}</div> <div className="p-1">{"|"}</div>
<div <div
@ -901,7 +893,7 @@ function ObjectTrainGrid({
// selection // selection
const [selectedEvent, setSelectedEvent] = useState<Event>(); const [selectedEvent, setSelectedEvent] = useState<Event>();
const [dialogTab, setDialogTab] = useState<SearchTab>("snapshot"); const [dialogTab, setDialogTab] = useState<SearchTab>("details");
// handlers // handlers
@ -969,7 +961,6 @@ function ObjectTrainGrid({
selectedItems={selectedImages} selectedItems={selectedImages}
i18nLibrary="views/classificationModel" i18nLibrary="views/classificationModel"
objectType={model.object_config?.objects?.at(0) ?? "Object"} objectType={model.object_config?.objects?.at(0) ?? "Object"}
noClassificationLabel="details.none"
onClick={(data) => { onClick={(data) => {
if (data) { if (data) {
onClickImages([data.filename], true); onClickImages([data.filename], true);

View File

@ -136,7 +136,7 @@ export default function EventView({
const [selectedReviews, setSelectedReviews] = useState<ReviewSegment[]>([]); const [selectedReviews, setSelectedReviews] = useState<ReviewSegment[]>([]);
const onSelectReview = useCallback( const onSelectReview = useCallback(
(review: ReviewSegment, ctrl: boolean, detail: boolean) => { (review: ReviewSegment, ctrl: boolean) => {
if (selectedReviews.length > 0 || ctrl) { if (selectedReviews.length > 0 || ctrl) {
const index = selectedReviews.findIndex((r) => r.id === review.id); const index = selectedReviews.findIndex((r) => r.id === review.id);
@ -156,31 +156,17 @@ export default function EventView({
setSelectedReviews(copy); setSelectedReviews(copy);
} }
} else { } 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({ onOpenRecording({
camera: review.camera, camera: review.camera,
startTime: effectiveStartTime - REVIEW_PADDING, startTime: review.start_time - REVIEW_PADDING,
severity: review.severity, severity: review.severity,
timelineType: detail ? "detail" : undefined,
}); });
review.has_been_reviewed = true; review.has_been_reviewed = true;
markItemAsReviewed(review); markItemAsReviewed(review);
} }
}, },
[ [selectedReviews, setSelectedReviews, onOpenRecording, markItemAsReviewed],
selectedReviews,
setSelectedReviews,
onOpenRecording,
markItemAsReviewed,
timeRange.after,
],
); );
const onSelectAllReviews = useCallback(() => { const onSelectAllReviews = useCallback(() => {
if (!currentReviewItems || currentReviewItems.length == 0) { if (!currentReviewItems || currentReviewItems.length == 0) {
@ -416,6 +402,7 @@ export default function EventView({
onSelectAllReviews={onSelectAllReviews} onSelectAllReviews={onSelectAllReviews}
setSelectedReviews={setSelectedReviews} setSelectedReviews={setSelectedReviews}
pullLatestData={pullLatestData} pullLatestData={pullLatestData}
onOpenRecording={onOpenRecording}
/> />
)} )}
{severity == "significant_motion" && ( {severity == "significant_motion" && (
@ -455,14 +442,11 @@ type DetectionReviewProps = {
loading: boolean; loading: boolean;
markItemAsReviewed: (review: ReviewSegment) => void; markItemAsReviewed: (review: ReviewSegment) => void;
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
onSelectReview: ( onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
review: ReviewSegment,
ctrl: boolean,
detail: boolean,
) => void;
onSelectAllReviews: () => void; onSelectAllReviews: () => void;
setSelectedReviews: (reviews: ReviewSegment[]) => void; setSelectedReviews: (reviews: ReviewSegment[]) => void;
pullLatestData: () => void; pullLatestData: () => void;
onOpenRecording: (recordingInfo: RecordingStartingPoint) => void;
}; };
function DetectionReview({ function DetectionReview({
contentRef, contentRef,
@ -482,6 +466,7 @@ function DetectionReview({
onSelectAllReviews, onSelectAllReviews,
setSelectedReviews, setSelectedReviews,
pullLatestData, pullLatestData,
onOpenRecording,
}: DetectionReviewProps) { }: DetectionReviewProps) {
const { t } = useTranslation(["views/events"]); const { t } = useTranslation(["views/events"]);
@ -773,7 +758,16 @@ function DetectionReview({
ctrl: boolean, ctrl: boolean,
detail: boolean, detail: boolean,
) => { ) => {
onSelectReview(review, ctrl, detail); if (detail) {
onOpenRecording({
camera: review.camera,
startTime: review.start_time - REVIEW_PADDING,
severity: review.severity,
timelineType: "detail",
});
} else {
onSelectReview(review, ctrl);
}
}} }}
/> />
</div> </div>

View File

@ -970,11 +970,12 @@ function Timeline({
"relative overflow-hidden", "relative overflow-hidden",
isDesktop isDesktop
? cn( ? cn(
"no-scrollbar overflow-y-auto",
timelineType == "timeline" timelineType == "timeline"
? "w-[100px] flex-shrink-0" ? "w-[100px] flex-shrink-0"
: timelineType == "detail" : 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%]" ? "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-80 flex-shrink-0", : "w-60 flex-shrink-0",
) )
: cn( : cn(
timelineType == "timeline" timelineType == "timeline"

View File

@ -214,7 +214,7 @@ export default function SearchView({
// detail // detail
const [searchDetail, setSearchDetail] = useState<SearchResult>(); const [searchDetail, setSearchDetail] = useState<SearchResult>();
const [page, setPage] = useState<SearchTab>("snapshot"); const [page, setPage] = useState<SearchTab>("details");
// search interaction // search interaction
@ -222,7 +222,7 @@ export default function SearchView({
const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onSelectSearch = useCallback( const onSelectSearch = useCallback(
(item: SearchResult, ctrl: boolean, page: SearchTab = "snapshot") => { (item: SearchResult, ctrl: boolean, page: SearchTab = "details") => {
if (selectedObjects.length > 1 || ctrl) { if (selectedObjects.length > 1 || ctrl) {
const index = selectedObjects.indexOf(item.id); const index = selectedObjects.indexOf(item.id);

View File

@ -717,11 +717,11 @@ export default function CameraSettingsView({
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]"> <div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button <Button
className="flex flex-1" className="flex flex-1"
aria-label={t("button.reset", { ns: "common" })} aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel} onClick={onCancel}
type="button" type="button"
> >
<Trans>button.reset</Trans> <Trans>button.cancel</Trans>
</Button> </Button>
<Button <Button
variant="select" variant="select"