Merge remote-tracking branch 'upstream/dev' into dev

This commit is contained in:
Rui Alves 2025-01-02 20:22:25 +00:00
commit a43af33c62
30 changed files with 5329 additions and 2280 deletions

View File

@ -75,15 +75,6 @@ jobs:
rpi.tags=${{ steps.setup.outputs.image-name }}-rpi rpi.tags=${{ steps.setup.outputs.image-name }}-rpi
*.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64 *.cache-from=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64
*.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64,mode=max *.cache-to=type=registry,ref=${{ steps.setup.outputs.cache-name }}-arm64,mode=max
- name: Build and push Rockchip build
uses: docker/bake-action@v3
with:
push: true
targets: rk
files: docker/rockchip/rk.hcl
set: |
rk.tags=${{ steps.setup.outputs.image-name }}-rk
*.cache-from=type=gha
jetson_jp4_build: jetson_jp4_build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Jetson Jetpack 4 name: Jetson Jetpack 4

View File

@ -76,7 +76,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.1.0 uses: actions/setup-python@v5.3.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Install requirements - name: Install requirements

View File

@ -1,12 +1,12 @@
appdirs==1.4.4 appdirs==1.4.*
argcomplete==2.0.0 argcomplete==2.0.*
contextlib2==0.6.0.post1 contextlib2==0.6.*
distlib==0.3.6 distlib==0.3.*
filelock==3.8.0 filelock==3.8.*
future==0.18.2 future==0.18.*
importlib-metadata==5.1.0 importlib-metadata==5.1.*
importlib-resources==5.1.2 importlib-resources==5.1.*
netaddr==0.8.0 netaddr==0.8.*
netifaces==0.10.9 netifaces==0.10.*
verboselogs==1.7 verboselogs==1.7.*
virtualenv==20.17.0 virtualenv==20.17.*

View File

@ -12,26 +12,11 @@ ARG TARGETARCH
COPY docker/tensorrt/requirements-amd64.txt /requirements-tensorrt.txt COPY docker/tensorrt/requirements-amd64.txt /requirements-tensorrt.txt
RUN mkdir -p /trt-wheels && pip3 wheel --wheel-dir=/trt-wheels -r /requirements-tensorrt.txt RUN mkdir -p /trt-wheels && pip3 wheel --wheel-dir=/trt-wheels -r /requirements-tensorrt.txt
# Build CuDNN
FROM wget AS cudnn-deps
ARG COMPUTE_LEVEL
RUN apt-get update \
&& apt-get install -y git build-essential
RUN wget https://developer.download.nvidia.com/compute/cuda/repos/debian11/x86_64/cuda-keyring_1.1-1_all.deb \
&& dpkg -i cuda-keyring_1.1-1_all.deb \
&& apt-get update \
&& apt-get -y install cuda-toolkit \
&& rm -rf /var/lib/apt/lists/*
FROM tensorrt-base AS frigate-tensorrt FROM tensorrt-base AS frigate-tensorrt
ENV TRT_VER=8.5.3 ENV TRT_VER=8.5.3
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 install -U /deps/trt-wheels/*.whl && \ pip3 install -U /deps/trt-wheels/*.whl && \
ldconfig ldconfig
COPY --from=cudnn-deps /usr/local/cuda-12.6 /usr/local/cuda
ENV LD_LIBRARY_PATH=/usr/local/lib/python3.9/dist-packages/tensorrt:/usr/local/cuda/lib64:/usr/local/lib/python3.9/dist-packages/nvidia/cufft/lib ENV LD_LIBRARY_PATH=/usr/local/lib/python3.9/dist-packages/tensorrt:/usr/local/cuda/lib64:/usr/local/lib/python3.9/dist-packages/nvidia/cufft/lib
WORKDIR /opt/frigate/ WORKDIR /opt/frigate/
@ -42,7 +27,7 @@ FROM devcontainer AS devcontainer-trt
COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so
COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos
COPY --from=cudnn-deps /usr/local/cuda-12.6 /usr/local/cuda COPY --from=trt-deps /usr/local/cuda-12.1 /usr/local/cuda
COPY docker/tensorrt/detector/rootfs/ / COPY docker/tensorrt/detector/rootfs/ /
COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so
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 \

View File

@ -24,6 +24,7 @@ ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0
COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so COPY --from=trt-deps /usr/local/lib/libyolo_layer.so /usr/local/lib/libyolo_layer.so
COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos COPY --from=trt-deps /usr/local/src/tensorrt_demos /usr/local/src/tensorrt_demos
COPY --from=trt-deps /usr/local/cuda-12.* /usr/local/cuda
COPY docker/tensorrt/detector/rootfs/ / COPY docker/tensorrt/detector/rootfs/ /
ENV YOLO_MODELS="" ENV YOLO_MODELS=""

View File

@ -41,6 +41,7 @@ cameras:
... ...
onvif: onvif:
# Required: host of the camera being connected to. # Required: host of the camera being connected to.
# NOTE: HTTP is assumed by default; HTTPS is supported if you specify the scheme, ex: "https://0.0.0.0".
host: 0.0.0.0 host: 0.0.0.0
# Optional: ONVIF port for device (default: shown below). # Optional: ONVIF port for device (default: shown below).
port: 8000 port: 8000
@ -49,6 +50,8 @@ cameras:
user: admin user: admin
# Optional: password for login. # Optional: password for login.
password: admin password: admin
# Optional: Skip TLS verification from the ONVIF server (default: shown below)
tls_insecure: False
# Optional: PTZ camera object autotracking. Keeps a moving object in # Optional: PTZ camera object autotracking. Keeps a moving object in
# the center of the frame by automatically moving the PTZ camera. # the center of the frame by automatically moving the PTZ camera.
autotracking: autotracking:

View File

@ -144,7 +144,9 @@ detectors:
#### SSDLite MobileNet v2 #### SSDLite MobileNet v2
An OpenVINO model is provided in the container at `/openvino-model/ssdlite_mobilenet_v2.xml` and is used by this detector type by default. The model comes from Intel's Open Model Zoo [SSDLite MobileNet V2](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/ssdlite_mobilenet_v2) and is converted to an FP16 precision IR model. Use the model configuration shown below when using the OpenVINO detector with the default model. An OpenVINO model is provided in the container at `/openvino-model/ssdlite_mobilenet_v2.xml` and is used by this detector type by default. The model comes from Intel's Open Model Zoo [SSDLite MobileNet V2](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/ssdlite_mobilenet_v2) and is converted to an FP16 precision IR model.
Use the model configuration shown below when using the OpenVINO detector with the default OpenVINO model:
```yaml ```yaml
detectors: detectors:
@ -254,6 +256,7 @@ yolov4x-mish-640
yolov7-tiny-288 yolov7-tiny-288
yolov7-tiny-416 yolov7-tiny-416
yolov7-640 yolov7-640
yolov7-416
yolov7-320 yolov7-320
yolov7x-640 yolov7x-640
yolov7x-320 yolov7x-320
@ -282,6 +285,8 @@ The TensorRT detector can be selected by specifying `tensorrt` as the model type
The TensorRT detector uses `.trt` model files that are located in `/config/model_cache/tensorrt` by default. These model path and dimensions used will depend on which model you have generated. The TensorRT detector uses `.trt` model files that are located in `/config/model_cache/tensorrt` by default. These model path and dimensions used will depend on which model you have generated.
Use the config below to work with generated TRT models:
```yaml ```yaml
detectors: detectors:
tensorrt: tensorrt:

View File

@ -117,25 +117,27 @@ auth:
hash_iterations: 600000 hash_iterations: 600000
# Optional: model modifications # Optional: model modifications
# NOTE: The default values are for the EdgeTPU detector.
# Other detectors will require the model config to be set.
model: model:
# Optional: path to the model (default: automatic based on detector) # Required: path to the model (default: automatic based on detector)
path: /edgetpu_model.tflite path: /edgetpu_model.tflite
# Optional: path to the labelmap (default: shown below) # Required: path to the labelmap (default: shown below)
labelmap_path: /labelmap.txt labelmap_path: /labelmap.txt
# Required: Object detection model input width (default: shown below) # Required: Object detection model input width (default: shown below)
width: 320 width: 320
# Required: Object detection model input height (default: shown below) # Required: Object detection model input height (default: shown below)
height: 320 height: 320
# Optional: Object detection model input colorspace # Required: Object detection model input colorspace
# Valid values are rgb, bgr, or yuv. (default: shown below) # Valid values are rgb, bgr, or yuv. (default: shown below)
input_pixel_format: rgb input_pixel_format: rgb
# Optional: Object detection model input tensor format # Required: Object detection model input tensor format
# Valid values are nhwc or nchw (default: shown below) # Valid values are nhwc or nchw (default: shown below)
input_tensor: nhwc input_tensor: nhwc
# Optional: Object detection model type, currently only used with the OpenVINO detector # Required: Object detection model type, currently only used with the OpenVINO detector
# Valid values are ssd, yolox, yolonas (default: shown below) # Valid values are ssd, yolox, yolonas (default: shown below)
model_type: ssd model_type: ssd
# Optional: Label name modifications. These are merged into the standard labelmap. # Required: Label name modifications. These are merged into the standard labelmap.
labelmap: labelmap:
2: vehicle 2: vehicle
# Optional: Map of object labels to their attribute labels (default: depends on model) # Optional: Map of object labels to their attribute labels (default: depends on model)
@ -686,6 +688,7 @@ cameras:
# to enable PTZ controls. # to enable PTZ controls.
onvif: onvif:
# Required: host of the camera being connected to. # Required: host of the camera being connected to.
# NOTE: HTTP is assumed by default; HTTPS is supported if you specify the scheme, ex: "https://0.0.0.0".
host: 0.0.0.0 host: 0.0.0.0
# Optional: ONVIF port for device (default: shown below). # Optional: ONVIF port for device (default: shown below).
port: 8000 port: 8000
@ -694,6 +697,8 @@ cameras:
user: admin user: admin
# Optional: password for login. # Optional: password for login.
password: admin password: admin
# Optional: Skip TLS verification from the ONVIF server (default: shown below)
tls_insecure: False
# Optional: Ignores time synchronization mismatches between the camera and the server during authentication. # Optional: Ignores time synchronization mismatches between the camera and the server during authentication.
# Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents. # Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents.
ignore_time_mismatch: False ignore_time_mismatch: False
@ -757,6 +762,8 @@ cameras:
- cat - cat
# Optional: Restrict generation to objects that entered any of the listed zones (default: none, all zones qualify) # Optional: Restrict generation to objects that entered any of the listed zones (default: none, all zones qualify)
required_zones: [] required_zones: []
# Optional: Save thumbnails sent to generative AI for review/debugging purposes (default: shown below)
debug_save_thumbnails: False
# Optional # Optional
ui: ui:

7069
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,15 +17,15 @@
"write-heading-ids": "docusaurus write-heading-ids" "write-heading-ids": "docusaurus write-heading-ids"
}, },
"dependencies": { "dependencies": {
"@docusaurus/core": "^3.5.2", "@docusaurus/core": "^3.6.3",
"@docusaurus/preset-classic": "^3.5.2", "@docusaurus/preset-classic": "^3.6.3",
"@docusaurus/theme-mermaid": "^3.5.2", "@docusaurus/theme-mermaid": "^3.6.3",
"@docusaurus/plugin-content-docs": "^3.5.2", "@docusaurus/plugin-content-docs": "^3.6.3",
"@mdx-js/react": "^3.0.1", "@mdx-js/react": "^3.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"docusaurus-plugin-openapi-docs": "^4.1.0", "docusaurus-plugin-openapi-docs": "^4.3.1",
"docusaurus-theme-openapi-docs": "^4.1.0", "docusaurus-theme-openapi-docs": "^4.3.1",
"prism-react-renderer": "^2.4.0", "prism-react-renderer": "^2.4.1",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"

View File

@ -139,6 +139,8 @@ def config(request: Request):
mode="json", warnings="none", exclude_none=True mode="json", warnings="none", exclude_none=True
) )
for stream_name, stream in go2rtc.get("streams", {}).items(): for stream_name, stream in go2rtc.get("streams", {}).items():
if stream is None:
continue
if isinstance(stream, str): if isinstance(stream, str):
cleaned = clean_camera_user_pass(stream) cleaned = clean_camera_user_pass(stream)
else: else:

View File

@ -437,7 +437,7 @@ class FrigateApp:
# pre-create shms # pre-create shms
for i in range(shm_frame_count): for i in range(shm_frame_count):
frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1] frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1]
self.frame_manager.create(f"{config.name}_{i}", frame_size) self.frame_manager.create(f"{config.name}_frame{i}", frame_size)
capture_process = util.Process( capture_process = util.Process(
target=capture_camera, target=capture_camera,

View File

@ -38,6 +38,10 @@ class GenAICameraConfig(BaseModel):
default_factory=list, default_factory=list,
title="List of required zones to be entered in order to run generative AI.", title="List of required zones to be entered in order to run generative AI.",
) )
debug_save_thumbnails: bool = Field(
default=False,
title="Save thumbnails sent to generative AI for debugging purposes.",
)
@field_validator("required_zones", mode="before") @field_validator("required_zones", mode="before")
@classmethod @classmethod

View File

@ -74,6 +74,7 @@ class OnvifConfig(FrigateBaseModel):
port: int = Field(default=8000, title="Onvif Port") port: int = Field(default=8000, title="Onvif Port")
user: Optional[EnvString] = Field(default=None, title="Onvif Username") user: Optional[EnvString] = Field(default=None, title="Onvif Username")
password: Optional[EnvString] = Field(default=None, title="Onvif Password") password: Optional[EnvString] = Field(default=None, title="Onvif Password")
tls_insecure: bool = Field(default=False, title="Onvif Disable TLS verification")
autotracking: PtzAutotrackConfig = Field( autotracking: PtzAutotrackConfig = Field(
default_factory=PtzAutotrackConfig, default_factory=PtzAutotrackConfig,
title="PTZ auto tracking config.", title="PTZ auto tracking config.",

View File

@ -4,6 +4,7 @@ from typing import Optional
from pydantic import Field from pydantic import Field
from frigate.const import MAX_PRE_CAPTURE from frigate.const import MAX_PRE_CAPTURE
from frigate.review.types import SeverityEnum
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
@ -101,3 +102,15 @@ class RecordConfig(FrigateBaseModel):
self.alerts.pre_capture, self.alerts.pre_capture,
self.detections.pre_capture, self.detections.pre_capture,
) )
def get_review_pre_capture(self, severity: SeverityEnum) -> int:
if severity == SeverityEnum.alert:
return self.alerts.pre_capture
else:
return self.detections.pre_capture
def get_review_post_capture(self, severity: SeverityEnum) -> int:
if severity == SeverityEnum.alert:
return self.alerts.post_capture
else:
return self.detections.post_capture

View File

@ -5,6 +5,7 @@ import logging
import os import os
import threading import threading
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
from pathlib import Path
from typing import Optional from typing import Optional
import cv2 import cv2
@ -217,6 +218,8 @@ class EmbeddingMaintainer(threading.Thread):
_, buffer = cv2.imencode(".jpg", cropped_image) _, buffer = cv2.imencode(".jpg", cropped_image)
snapshot_image = buffer.tobytes() snapshot_image = buffer.tobytes()
num_thumbnails = len(self.tracked_events.get(event_id, []))
embed_image = ( embed_image = (
[snapshot_image] [snapshot_image]
if event.has_snapshot and camera_config.genai.use_snapshot if event.has_snapshot and camera_config.genai.use_snapshot
@ -225,11 +228,37 @@ class EmbeddingMaintainer(threading.Thread):
data["thumbnail"] data["thumbnail"]
for data in self.tracked_events[event_id] for data in self.tracked_events[event_id]
] ]
if len(self.tracked_events.get(event_id, [])) > 0 if num_thumbnails > 0
else [thumbnail] else [thumbnail]
) )
) )
if camera_config.genai.debug_save_thumbnails and num_thumbnails > 0:
logger.debug(
f"Saving {num_thumbnails} thumbnails for event {event.id}"
)
Path(
os.path.join(CLIPS_DIR, f"genai-requests/{event.id}")
).mkdir(parents=True, exist_ok=True)
for idx, data in enumerate(self.tracked_events[event_id], 1):
jpg_bytes: bytes = data["thumbnail"]
if jpg_bytes is None:
logger.warning(
f"Unable to save thumbnail {idx} for {event.id}."
)
else:
with open(
os.path.join(
CLIPS_DIR,
f"genai-requests/{event.id}/{idx}.jpg",
),
"wb",
) as j:
j.write(jpg_bytes)
# Generate the description. Call happens in a thread since it is network bound. # Generate the description. Call happens in a thread since it is network bound.
threading.Thread( threading.Thread(
target=self._embed_description, target=self._embed_description,

View File

@ -121,8 +121,8 @@ class EventCleanup(threading.Thread):
events_to_update = [] events_to_update = []
for batch in query.iterator(): for event in query.iterator():
events_to_update.extend([event.id for event in batch]) events_to_update.append(event.id)
if len(events_to_update) >= CHUNK_SIZE: if len(events_to_update) >= CHUNK_SIZE:
logger.debug( logger.debug(
f"Updating {update_params} for {len(events_to_update)} events" f"Updating {update_params} for {len(events_to_update)} events"
@ -257,7 +257,7 @@ class EventCleanup(threading.Thread):
events_to_update = [] events_to_update = []
for event in query.iterator(): for event in query.iterator():
events_to_update.append(event) events_to_update.append(event.id)
if len(events_to_update) >= CHUNK_SIZE: if len(events_to_update) >= CHUNK_SIZE:
logger.debug( logger.debug(

View File

@ -6,6 +6,7 @@ from importlib.util import find_spec
from pathlib import Path from pathlib import Path
import numpy import numpy
import requests
from onvif import ONVIFCamera, ONVIFError from onvif import ONVIFCamera, ONVIFError
from zeep.exceptions import Fault, TransportError from zeep.exceptions import Fault, TransportError
from zeep.transports import Transport from zeep.transports import Transport
@ -48,7 +49,11 @@ class OnvifController:
if cam.onvif.host: if cam.onvif.host:
try: try:
transport = Transport(timeout=10, operation_timeout=10) session = requests.Session()
session.verify = not cam.onvif.tls_insecure
transport = Transport(
timeout=10, operation_timeout=10, session=session
)
self.cams[cam_name] = { self.cams[cam_name] = {
"onvif": ONVIFCamera( "onvif": ONVIFCamera(
cam.onvif.host, cam.onvif.host,
@ -558,22 +563,26 @@ class OnvifController:
if not self._init_onvif(camera_name): if not self._init_onvif(camera_name):
return return
if command == OnvifCommandEnum.init: try:
# already init if command == OnvifCommandEnum.init:
return # already init
elif command == OnvifCommandEnum.stop: return
self._stop(camera_name) elif command == OnvifCommandEnum.stop:
elif command == OnvifCommandEnum.preset: self._stop(camera_name)
self._move_to_preset(camera_name, param) elif command == OnvifCommandEnum.preset:
elif command == OnvifCommandEnum.move_relative: self._move_to_preset(camera_name, param)
_, pan, tilt = param.split("_") elif command == OnvifCommandEnum.move_relative:
self._move_relative(camera_name, float(pan), float(tilt), 0, 1) _, pan, tilt = param.split("_")
elif ( self._move_relative(camera_name, float(pan), float(tilt), 0, 1)
command == OnvifCommandEnum.zoom_in or command == OnvifCommandEnum.zoom_out elif (
): command == OnvifCommandEnum.zoom_in
self._zoom(camera_name, command) or command == OnvifCommandEnum.zoom_out
else: ):
self._move(camera_name, command) self._zoom(camera_name, command)
else:
self._move(camera_name, command)
except ONVIFError as e:
logger.error(f"Unable to handle onvif command: {e}")
def get_camera_info(self, camera_name: str) -> dict[str, any]: def get_camera_info(self, camera_name: str) -> dict[str, any]:
if camera_name not in self.cams.keys(): if camera_name not in self.cams.keys():

View File

@ -29,6 +29,7 @@ from frigate.const import (
RECORD_DIR, RECORD_DIR,
) )
from frigate.models import Recordings, ReviewSegment from frigate.models import Recordings, ReviewSegment
from frigate.review.types import SeverityEnum
from frigate.util.services import get_video_properties from frigate.util.services import get_video_properties
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -194,6 +195,7 @@ class RecordingMaintainer(threading.Thread):
ReviewSegment.select( ReviewSegment.select(
ReviewSegment.start_time, ReviewSegment.start_time,
ReviewSegment.end_time, ReviewSegment.end_time,
ReviewSegment.severity,
ReviewSegment.data, ReviewSegment.data,
) )
.where( .where(
@ -219,11 +221,15 @@ class RecordingMaintainer(threading.Thread):
[r for r in recordings_to_insert if r is not None], [r for r in recordings_to_insert if r is not None],
) )
def drop_segment(self, cache_path: str) -> None:
Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
async def validate_and_move_segment( async def validate_and_move_segment(
self, camera: str, reviews: list[ReviewSegment], recording: dict[str, any] self, camera: str, reviews: list[ReviewSegment], recording: dict[str, any]
) -> None: ) -> None:
cache_path = recording["cache_path"] cache_path: str = recording["cache_path"]
start_time = recording["start_time"] start_time: datetime.datetime = recording["start_time"]
record_config = self.config.cameras[camera].record record_config = self.config.cameras[camera].record
# Just delete files if recordings are turned off # Just delete files if recordings are turned off
@ -231,8 +237,7 @@ class RecordingMaintainer(threading.Thread):
camera not in self.config.cameras camera not in self.config.cameras
or not self.config.cameras[camera].record.enabled or not self.config.cameras[camera].record.enabled
): ):
Path(cache_path).unlink(missing_ok=True) self.drop_segment(cache_path)
self.end_time_cache.pop(cache_path, None)
return return
if cache_path in self.end_time_cache: if cache_path in self.end_time_cache:
@ -260,24 +265,34 @@ class RecordingMaintainer(threading.Thread):
return return
# if cached file's start_time is earlier than the retain days for the camera # if cached file's start_time is earlier than the retain days for the camera
# meaning continuous recording is not enabled
if start_time <= ( if start_time <= (
datetime.datetime.now().astimezone(datetime.timezone.utc) datetime.datetime.now().astimezone(datetime.timezone.utc)
- datetime.timedelta(days=self.config.cameras[camera].record.retain.days) - datetime.timedelta(days=self.config.cameras[camera].record.retain.days)
): ):
# if the cached segment overlaps with the events: # if the cached segment overlaps with the review items:
overlaps = False overlaps = False
for review in reviews: for review in reviews:
# if the event starts in the future, stop checking events severity = SeverityEnum[review.severity]
# if the review item starts in the future, stop checking review items
# and remove this segment # and remove this segment
if review.start_time > end_time.timestamp(): if (
review.start_time - record_config.get_review_pre_capture(severity)
) > end_time.timestamp():
overlaps = False overlaps = False
Path(cache_path).unlink(missing_ok=True)
self.end_time_cache.pop(cache_path, None)
break break
# if the event is in progress or ends after the recording starts, keep it # if the review item is in progress or ends after the recording starts, keep it
# and stop looking at events # and stop looking at review items
if review.end_time is None or review.end_time >= start_time.timestamp(): if (
review.end_time is None
or (
review.end_time
+ record_config.get_review_post_capture(severity)
)
>= start_time.timestamp()
):
overlaps = True overlaps = True
break break
@ -296,7 +311,7 @@ class RecordingMaintainer(threading.Thread):
cache_path, cache_path,
record_mode, record_mode,
) )
# if it doesn't overlap with an event, go ahead and drop the segment # if it doesn't overlap with an review item, go ahead and drop the segment
# if it ends more than the configured pre_capture for the camera # if it ends more than the configured pre_capture for the camera
else: else:
camera_info = self.object_recordings_info[camera] camera_info = self.object_recordings_info[camera]
@ -307,9 +322,9 @@ class RecordingMaintainer(threading.Thread):
most_recently_processed_frame_time - record_config.event_pre_capture most_recently_processed_frame_time - record_config.event_pre_capture
).astimezone(datetime.timezone.utc) ).astimezone(datetime.timezone.utc)
if end_time < retain_cutoff: if end_time < retain_cutoff:
Path(cache_path).unlink(missing_ok=True) self.drop_segment(cache_path)
self.end_time_cache.pop(cache_path, None)
# else retain days includes this segment # else retain days includes this segment
# meaning continuous recording is enabled
else: else:
# assume that empty means the relevant recording info has not been received yet # assume that empty means the relevant recording info has not been received yet
camera_info = self.object_recordings_info[camera] camera_info = self.object_recordings_info[camera]
@ -390,8 +405,7 @@ class RecordingMaintainer(threading.Thread):
# check if the segment shouldn't be stored # check if the segment shouldn't be stored
if segment_info.should_discard_segment(store_mode): if segment_info.should_discard_segment(store_mode):
Path(cache_path).unlink(missing_ok=True) self.drop_segment(cache_path)
self.end_time_cache.pop(cache_path, None)
return return
# directory will be in utc due to start_time being in utc # directory will be in utc due to start_time being in utc

View File

@ -293,7 +293,7 @@ def stats_snapshot(
for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]: for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]:
try: try:
storage_stats = shutil.disk_usage(path) storage_stats = shutil.disk_usage(path)
except FileNotFoundError: except (FileNotFoundError, OSError):
stats["service"]["storage"][path] = {} stats["service"]["storage"][path] = {}
continue continue

View File

@ -113,7 +113,7 @@ def capture_frames(
fps.value = frame_rate.eps() fps.value = frame_rate.eps()
skipped_fps.value = skipped_eps.eps() skipped_fps.value = skipped_eps.eps()
current_frame.value = datetime.datetime.now().timestamp() current_frame.value = datetime.datetime.now().timestamp()
frame_name = f"{config.name}_{frame_index}" frame_name = f"{config.name}_frame{frame_index}"
frame_buffer = frame_manager.write(frame_name) frame_buffer = frame_manager.write(frame_name)
try: try:
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size) frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)

View File

@ -755,7 +755,11 @@ export function CameraGroupEdit({
<FormMessage /> <FormMessage />
{[ {[
...(birdseyeConfig?.enabled ? ["birdseye"] : []), ...(birdseyeConfig?.enabled ? ["birdseye"] : []),
...Object.keys(config?.cameras ?? {}), ...Object.keys(config?.cameras ?? {}).sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
].map((camera) => ( ].map((camera) => (
<FormControl key={camera}> <FormControl key={camera}>
<FilterSwitch <FilterSwitch

View File

@ -477,7 +477,10 @@ export default function ObjectLifecycle({
</p> </p>
{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(2) ? (
aspectRatio *
(item.data.box[2] / item.data.box[3])
).toFixed(2)
: "N/A"} : "N/A"}
</div> </div>
</div> </div>

View File

@ -505,45 +505,46 @@ function ObjectDetailsTab({
<div className="flex w-full flex-row justify-end gap-2"> <div className="flex w-full flex-row justify-end gap-2">
{config?.cameras[search.camera].genai.enabled && search.end_time && ( {config?.cameras[search.camera].genai.enabled && search.end_time && (
<> <div className="flex items-start">
<div className="flex items-start"> <Button
<Button className="rounded-r-none border-r-0"
className="rounded-r-none border-r-0" aria-label="Regenerate tracked object description"
aria-label="Regenerate tracked object description" onClick={() => regenerateDescription("thumbnails")}
onClick={() => regenerateDescription("thumbnails")} >
> Regenerate
Regenerate </Button>
</Button> {search.has_snapshot && (
{search.has_snapshot && ( <DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <Button
<Button className="rounded-l-none border-l-0 px-2"
className="rounded-l-none border-l-0 px-2" aria-label="Expand regeneration menu"
aria-label="Expand regeneration menu" >
> <FaChevronDown className="size-3" />
<FaChevronDown className="size-3" /> </Button>
</Button> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuContent>
<DropdownMenuContent> <DropdownMenuItem
<DropdownMenuItem className="cursor-pointer"
className="cursor-pointer" aria-label="Regenerate from snapshot"
aria-label="Regenerate from snapshot" onClick={() => regenerateDescription("snapshot")}
onClick={() => regenerateDescription("snapshot")} >
> Regenerate from Snapshot
Regenerate from Snapshot </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuItem
<DropdownMenuItem className="cursor-pointer"
className="cursor-pointer" aria-label="Regenerate from thumbnails"
aria-label="Regenerate from thumbnails" onClick={() => regenerateDescription("thumbnails")}
onClick={() => regenerateDescription("thumbnails")} >
> Regenerate from Thumbnails
Regenerate from Thumbnails </DropdownMenuItem>
</DropdownMenuItem> </DropdownMenuContent>
</DropdownMenuContent> </DropdownMenu>
</DropdownMenu> )}
)} </div>
</div> )}
{(config?.cameras[search.camera].genai.enabled && search.end_time) ||
(!config?.cameras[search.camera].genai.enabled && (
<Button <Button
variant="select" variant="select"
aria-label="Save" aria-label="Save"
@ -551,8 +552,7 @@ function ObjectDetailsTab({
> >
Save Save
</Button> </Button>
</> ))}
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -46,7 +46,7 @@ export default function SearchSettings({
const trigger = ( const trigger = (
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Search Settings" aria-label="Explore Settings"
size="sm" size="sm"
> >
<FaCog className="text-secondary-foreground" /> <FaCog className="text-secondary-foreground" />

View File

@ -328,12 +328,12 @@ export default function Explore() {
<div className="flex max-w-96 flex-col items-center justify-center space-y-3 rounded-lg bg-background/50 p-5"> <div className="flex max-w-96 flex-col items-center justify-center space-y-3 rounded-lg bg-background/50 p-5">
<div className="my-5 flex flex-col items-center gap-2 text-xl"> <div className="my-5 flex flex-col items-center gap-2 text-xl">
<TbExclamationCircle className="mb-3 size-10" /> <TbExclamationCircle className="mb-3 size-10" />
<div>Search Unavailable</div> <div>Explore is Unavailable</div>
</div> </div>
{embeddingsReindexing && allModelsLoaded && ( {embeddingsReindexing && allModelsLoaded && (
<> <>
<div className="text-center text-primary-variant"> <div className="text-center text-primary-variant">
Search can be used after tracked object embeddings have Explore can be used after tracked object embeddings have
finished reindexing. finished reindexing.
</div> </div>
<div className="pt-5 text-center"> <div className="pt-5 text-center">
@ -384,8 +384,8 @@ export default function Explore() {
<> <>
<div className="text-center text-primary-variant"> <div className="text-center text-primary-variant">
Frigate is downloading the necessary embeddings models to Frigate is downloading the necessary embeddings models to
support semantic searching. This may take several minutes support the Semantic Search feature. This may take several
depending on the speed of your network connection. minutes depending on the speed of your network connection.
</div> </div>
<div className="flex w-96 flex-col gap-2 py-5"> <div className="flex w-96 flex-col gap-2 py-5">
<div className="flex flex-row items-center justify-center gap-2"> <div className="flex flex-row items-center justify-center gap-2">

View File

@ -40,7 +40,7 @@ import UiSettingsView from "@/views/settings/UiSettingsView";
const allSettingsViews = [ const allSettingsViews = [
"UI settings", "UI settings",
"search settings", "explore settings",
"camera settings", "camera settings",
"masks / zones", "masks / zones",
"motion tuner", "motion tuner",
@ -175,7 +175,7 @@ export default function Settings() {
</div> </div>
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24"> <div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
{page == "UI settings" && <UiSettingsView />} {page == "UI settings" && <UiSettingsView />}
{page == "search settings" && ( {page == "explore settings" && (
<SearchSettingsView setUnsavedChanges={setUnsavedChanges} /> <SearchSettingsView setUnsavedChanges={setUnsavedChanges} />
)} )}
{page == "debug" && ( {page == "debug" && (

View File

@ -142,6 +142,7 @@ export interface CameraConfig {
password: string | null; password: string | null;
port: number; port: number;
user: string | null; user: string | null;
tls_insecure: boolean;
}; };
record: { record: {
enabled: boolean; enabled: boolean;

View File

@ -17,7 +17,12 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { TooltipProvider } from "@/components/ui/tooltip"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
@ -29,6 +34,7 @@ import {
import { CameraPtzInfo } from "@/types/ptz"; import { CameraPtzInfo } from "@/types/ptz";
import { RecordingStartingPoint } from "@/types/record"; import { RecordingStartingPoint } from "@/types/record";
import React, { import React, {
ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
@ -518,6 +524,53 @@ export default function LiveCameraView({
); );
} }
type TooltipButtonProps = {
label: string;
onClick?: () => void;
onMouseDown?: (e: React.MouseEvent) => void;
onMouseUp?: (e: React.MouseEvent) => void;
onTouchStart?: (e: React.TouchEvent) => void;
onTouchEnd?: (e: React.TouchEvent) => void;
children: ReactNode;
className?: string;
};
function TooltipButton({
label,
onClick,
onMouseDown,
onMouseUp,
onTouchStart,
onTouchEnd,
children,
className,
...props
}: TooltipButtonProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={label}
onClick={onClick}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
className={className}
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function PtzControlPanel({ function PtzControlPanel({
camera, camera,
clickOverlay, clickOverlay,
@ -611,8 +664,8 @@ function PtzControlPanel({
> >
{ptz?.features?.includes("pt") && ( {ptz?.features?.includes("pt") && (
<> <>
<Button <TooltipButton
aria-label="Move PTZ camera to the left" label="Move camera left"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("MOVE_LEFT"); sendPtz("MOVE_LEFT");
@ -625,9 +678,9 @@ function PtzControlPanel({
onTouchEnd={onStop} onTouchEnd={onStop}
> >
<FaAngleLeft /> <FaAngleLeft />
</Button> </TooltipButton>
<Button <TooltipButton
aria-label="Move PTZ camera up" label="Move camera up"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("MOVE_UP"); sendPtz("MOVE_UP");
@ -640,9 +693,9 @@ function PtzControlPanel({
onTouchEnd={onStop} onTouchEnd={onStop}
> >
<FaAngleUp /> <FaAngleUp />
</Button> </TooltipButton>
<Button <TooltipButton
aria-label="Move PTZ camera down" label="Move camera down"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("MOVE_DOWN"); sendPtz("MOVE_DOWN");
@ -655,9 +708,9 @@ function PtzControlPanel({
onTouchEnd={onStop} onTouchEnd={onStop}
> >
<FaAngleDown /> <FaAngleDown />
</Button> </TooltipButton>
<Button <TooltipButton
aria-label="Move PTZ camera to the right" label="Move camera right"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("MOVE_RIGHT"); sendPtz("MOVE_RIGHT");
@ -670,13 +723,13 @@ function PtzControlPanel({
onTouchEnd={onStop} onTouchEnd={onStop}
> >
<FaAngleRight /> <FaAngleRight />
</Button> </TooltipButton>
</> </>
)} )}
{ptz?.features?.includes("zoom") && ( {ptz?.features?.includes("zoom") && (
<> <>
<Button <TooltipButton
aria-label="Zoom PTZ camera in" label="Zoom in"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("ZOOM_IN"); sendPtz("ZOOM_IN");
@ -689,9 +742,9 @@ function PtzControlPanel({
onTouchEnd={onStop} onTouchEnd={onStop}
> >
<MdZoomIn /> <MdZoomIn />
</Button> </TooltipButton>
<Button <TooltipButton
aria-label="Zoom PTZ camera out" label="Zoom out"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
sendPtz("ZOOM_OUT"); sendPtz("ZOOM_OUT");
@ -704,45 +757,60 @@ function PtzControlPanel({
onTouchEnd={onStop} onTouchEnd={onStop}
> >
<MdZoomOut /> <MdZoomOut />
</Button> </TooltipButton>
</> </>
)} )}
{ptz?.features?.includes("pt-r-fov") && ( {ptz?.features?.includes("pt-r-fov") && (
<> <TooltipProvider>
<Button <Tooltip>
className={`${clickOverlay ? "text-selected" : "text-primary"}`} <TooltipTrigger asChild>
aria-label="Click in the frame to center the PTZ camera" <Button
onClick={() => setClickOverlay(!clickOverlay)} className={`${clickOverlay ? "text-selected" : "text-primary"}`}
> aria-label="Click in the frame to center the camera"
<TbViewfinder /> onClick={() => setClickOverlay(!clickOverlay)}
</Button> >
</> <TbViewfinder />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{clickOverlay ? "Disable" : "Enable"} click to move</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
{(ptz?.presets?.length ?? 0) > 0 && ( {(ptz?.presets?.length ?? 0) > 0 && (
<DropdownMenu modal={!isDesktop}> <TooltipProvider>
<DropdownMenuTrigger asChild> <Tooltip>
<Button aria-label="PTZ camera presets"> <TooltipTrigger asChild>
<BsThreeDotsVertical /> <DropdownMenu modal={!isDesktop}>
</Button> <DropdownMenuTrigger asChild>
</DropdownMenuTrigger> <Button aria-label="PTZ camera presets">
<DropdownMenuContent <BsThreeDotsVertical />
className="scrollbar-container max-h-[40dvh] overflow-y-auto" </Button>
onCloseAutoFocus={(e) => e.preventDefault()} </DropdownMenuTrigger>
> <DropdownMenuContent
{ptz?.presets.map((preset) => { className="scrollbar-container max-h-[40dvh] overflow-y-auto"
return ( onCloseAutoFocus={(e) => e.preventDefault()}
<DropdownMenuItem
key={preset}
aria-label={preset}
className="cursor-pointer"
onSelect={() => sendPtz(`preset_${preset}`)}
> >
{preset} {ptz?.presets.map((preset) => (
</DropdownMenuItem> <DropdownMenuItem
); key={preset}
})} aria-label={preset}
</DropdownMenuContent> className="cursor-pointer"
</DropdownMenu> onSelect={() => sendPtz(`preset_${preset}`)}
>
{preset}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent>
<p>PTZ camera presets</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
</div> </div>
); );

View File

@ -91,7 +91,7 @@ export default function SearchSettingsView({
) )
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success("Search settings have been saved.", { toast.success("Explore settings have been saved.", {
position: "top-center", position: "top-center",
}); });
setChangedValue(false); setChangedValue(false);
@ -128,7 +128,7 @@ export default function SearchSettingsView({
if (changedValue) { if (changedValue) {
addMessage( addMessage(
"search_settings", "search_settings",
`Unsaved search settings changes`, `Unsaved Explore settings changes`,
undefined, undefined,
"search_settings", "search_settings",
); );
@ -140,7 +140,7 @@ export default function SearchSettingsView({
}, [changedValue]); }, [changedValue]);
useEffect(() => { useEffect(() => {
document.title = "Search Settings - Frigate"; document.title = "Explore Settings - Frigate";
}, []); }, []);
if (!config) { if (!config) {
@ -152,7 +152,7 @@ export default function SearchSettingsView({
<Toaster position="top-center" closeButton={true} /> <Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0"> <div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2"> <Heading as="h3" className="my-2">
Search Settings Explore Settings
</Heading> </Heading>
<Separator className="my-2 flex bg-secondary" /> <Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2"> <Heading as="h4" className="my-2">
@ -221,7 +221,7 @@ export default function SearchSettingsView({
<div className="text-md">Model Size</div> <div className="text-md">Model Size</div>
<div className="space-y-1 text-sm text-muted-foreground"> <div className="space-y-1 text-sm text-muted-foreground">
<p> <p>
The size of the model used for semantic search embeddings. The size of the model used for Semantic Search embeddings.
</p> </p>
<ul className="list-disc pl-5 text-sm"> <ul className="list-disc pl-5 text-sm">
<li> <li>