diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index ca10bfc3d..26d729bdd 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -65,7 +65,7 @@ jobs: - name: Check out the repository uses: actions/checkout@v4 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.0.0 + uses: actions/setup-python@v5.1.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Install requirements diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 69da2a0e3..0e16269e3 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -4,15 +4,15 @@ imutils == 0.5.* markupsafe == 2.1.* matplotlib == 3.7.* mypy == 1.6.1 -numpy == 1.23.* +numpy == 1.26.* onvif_zeep == 0.2.12 opencv-python-headless == 4.7.0.* paho-mqtt == 2.0.* -pandas == 2.1.4 +pandas == 2.2.* peewee == 3.17.* peewee_migrate == 1.12.* psutil == 5.9.* -pydantic == 2.6.* +pydantic == 2.7.* git+https://github.com/fbcotter/py3nvml#egg=py3nvml PyYAML == 6.0.* pytz == 2024.1 diff --git a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf index 9f4687fa0..3ffd714fd 100644 --- a/docker/main/rootfs/usr/local/nginx/conf/nginx.conf +++ b/docker/main/rootfs/usr/local/nginx/conf/nginx.conf @@ -62,7 +62,7 @@ http { } server { - listen 5000; + listen [::]:5000 ipv6only=off; # vod settings vod_base_url ''; @@ -238,14 +238,14 @@ http { location /api/stats { access_log off; - rewrite ^/api/(.*)$ $1 break; + rewrite ^/api(/.*)$ $1 break; proxy_pass http://frigate_api; include proxy.conf; } location /api/version { access_log off; - rewrite ^/api/(.*)$ $1 break; + rewrite ^/api(/.*)$ $1 break; proxy_pass http://frigate_api; include proxy.conf; } diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 0591f9757..f603d33fb 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -257,6 +257,28 @@ objects: # Checks based on the bottom center of the bounding box of the object mask: 0,0,1000,0,1000,200,0,200 +# Optional: Review configuration +# NOTE: Can be overridden at the camera level +review: + # Optional: alerts configuration + alerts: + # Optional: labels that qualify as an alert (default: shown below) + labels: + - car + - person + # Optional: required zones for an object to be marked as an alert (default: none) + required_zones: + - driveway + # Optional: detections configuration + detections: + # Optional: labels that qualify as a detection (default: all labels that are tracked / listened to) + labels: + - car + - person + # Optional: required zones for an object to be marked as a detection (default: none) + required_zones: + - driveway + # Optional: Motion configuration # NOTE: Can be overridden at the camera level motion: @@ -345,8 +367,6 @@ record: # Optional: Objects to save recordings for. (default: all tracked objects) objects: - person - # Optional: Restrict recordings to objects that entered any of the listed zones (default: no required zones) - required_zones: [] # Optional: Retention settings for recordings of events retain: # Required: Default retention days (default: shown below) diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index 964b44f17..177c47075 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -19,17 +19,18 @@ To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the Often you will only want events to be created when an object enters areas of interest. This is done using zones along with setting required_zones. Let's say you only want to be notified when an object enters your entire_yard zone, the config would be: ```yaml -camera: - record: - events: +cameras: + name_of_your_camera: + record: + events: + required_zones: + - entire_yard + snapshots: required_zones: - entire_yard - snapshots: - required_zones: - - entire_yard - zones: - entire_yard: - coordinates: ... + zones: + entire_yard: + coordinates: ... ``` ### Restricting zones to specific objects @@ -37,25 +38,26 @@ camera: Sometimes you want to limit a zone to specific object types to have more granular control of when events/snapshots are saved. The following example will limit one zone to person objects and the other to cars. ```yaml -camera: - record: - events: +cameras: + name_of_your_camera: + record: + events: + required_zones: + - entire_yard + - front_yard_street + snapshots: required_zones: - entire_yard - front_yard_street - snapshots: - required_zones: - - entire_yard - - front_yard_street - zones: - entire_yard: - coordinates: ... (everywhere you want a person) - objects: - - person - front_yard_street: - coordinates: ... (just the street) - objects: - - car + zones: + entire_yard: + coordinates: ... (everywhere you want a person) + objects: + - person + front_yard_street: + coordinates: ... (just the street) + objects: + - car ``` Only car objects can trigger the `front_yard_street` zone and only person can trigger the `entire_yard`. You will get events for person objects that enter anywhere in the yard, and events for cars only if they enter the street. @@ -65,12 +67,13 @@ Only car objects can trigger the `front_yard_street` zone and only person can tr Sometimes objects are expected to be passing through a zone, but an object loitering in an area is unexpected. Zones can be configured to have a minimum loitering time before the object will be considered in the zone. ```yaml -camera: - zones: - sidewalk: - loitering_time: 4 # unit is in seconds - objects: - - person +cameras: + name_of_your_camera: + zones: + sidewalk: + loitering_time: 4 # unit is in seconds + objects: + - person ``` ### Zone Inertia @@ -78,21 +81,23 @@ camera: Sometimes an objects bounding box may be slightly incorrect and the bottom center of the bounding box is inside the zone while the object is not actually in the zone. Zone inertia helps guard against this by requiring an object's bounding box to be within the zone for multiple consecutive frames. This value can be configured: ```yaml -camera: - zones: - front_yard: - inertia: 3 - objects: - - person +cameras: + name_of_your_camera: + zones: + front_yard: + inertia: 3 + objects: + - person ``` There may also be cases where you expect an object to quickly enter and exit a zone, like when a car is pulling into the driveway, and you may want to have the object be considered present in the zone immediately: ```yaml -camera: - zones: - driveway_entrance: - inertia: 1 - objects: - - car +cameras: + name_of_your_camera: + zones: + driveway_entrance: + inertia: 1 + objects: + - car ``` diff --git a/frigate/api/app.py b/frigate/api/app.py index d307e9384..a324b6a05 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -137,7 +137,10 @@ def stats_history(): @bp.route("/config") def config(): - config = current_app.frigate_config.model_dump(mode="json", exclude_none=True) + config_obj: FrigateConfig = current_app.frigate_config + config: dict[str, dict[str, any]] = config_obj.model_dump( + mode="json", exclude_none=True + ) # remove the mqtt password config["mqtt"].pop("password", None) @@ -154,9 +157,13 @@ def config(): for cmd in camera_dict["ffmpeg_cmds"]: cmd["cmd"] = clean_camera_user_pass(" ".join(cmd["cmd"])) + # ensure that zones are relative + for zone_name, zone in config_obj.cameras[camera_name].zones.items(): + camera_dict["zones"][zone_name]["color"] = zone.color + config["plus"] = {"enabled": current_app.plus_api.is_active()} - for detector, detector_config in config["detectors"].items(): + for detector_config in config["detectors"].values(): detector_config["model"]["labelmap"] = ( current_app.frigate_config.model.merged_labelmap ) diff --git a/frigate/api/media.py b/frigate/api/media.py index c72d9b933..345636e25 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -1333,7 +1333,9 @@ def review_preview(id: str): padding = 8 start_ts = review.start_time - padding - end_ts = review.end_time + padding + end_ts = ( + review.end_time + padding if review.end_time else datetime.now().timestamp() + ) return preview_gif(review.camera, start_ts, end_ts) @@ -1344,8 +1346,15 @@ def preview_thumbnail(file_name: str): safe_file_name_current = secure_filename(file_name) preview_dir = os.path.join(CACHE_DIR, "preview_frames") - with open(os.path.join(preview_dir, safe_file_name_current), "rb") as image_file: - jpg_bytes = image_file.read() + try: + with open( + os.path.join(preview_dir, safe_file_name_current), "rb" + ) as image_file: + jpg_bytes = image_file.read() + except FileNotFoundError: + return make_response( + jsonify({"success": False, "message": "Image file not found"}), 404 + ) response = make_response(jpg_bytes) response.headers["Content-Type"] = "image/jpeg" diff --git a/frigate/api/review.py b/frigate/api/review.py index d3a49de9f..2ad36962e 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -27,10 +27,18 @@ def review(): before = request.args.get("before", type=float, default=datetime.now().timestamp()) after = request.args.get( - "after", type=float, default=(datetime.now() - timedelta(hours=18)).timestamp() + "after", type=float, default=(datetime.now() - timedelta(hours=24)).timestamp() ) - clauses = [((ReviewSegment.start_time > after) & (ReviewSegment.end_time < before))] + clauses = [ + ( + (ReviewSegment.start_time > after) + & ( + (ReviewSegment.end_time.is_null(True)) + | (ReviewSegment.end_time < before) + ) + ) + ] if cameras != "all": camera_list = cameras.split(",") @@ -45,6 +53,7 @@ def review(): for label in filtered_labels: label_clauses.append( (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') + | (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*') ) label_clause = reduce(operator.or_, label_clauses) @@ -94,6 +103,7 @@ def review_summary(): for label in filtered_labels: label_clauses.append( (ReviewSegment.data["objects"].cast("text") % f'*"{label}"*') + | (ReviewSegment.data["audio"].cast("text") % f'*"{label}"*') ) label_clause = reduce(operator.or_, label_clauses) @@ -429,12 +439,12 @@ def motion_activity(): # normalize data motion = ( df["motion"] - .resample(f"{scale}S") + .resample(f"{scale}s") .apply(lambda x: max(x, key=abs, default=0.0)) .fillna(0.0) .to_frame() ) - cameras = df["camera"].resample(f"{scale}S").agg(lambda x: ",".join(set(x))) + cameras = df["camera"].resample(f"{scale}s").agg(lambda x: ",".join(set(x))) df = motion.join(cameras) length = df.shape[0] diff --git a/frigate/app.py b/frigate/app.py index d0d5b76c7..a0722ecf2 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -63,6 +63,7 @@ from frigate.storage import StorageMaintainer from frigate.timeline import TimelineProcessor from frigate.types import CameraMetricsTypes, PTZMetricsTypes from frigate.util.builtin import save_default_config +from frigate.util.config import migrate_frigate_config from frigate.util.object import get_camera_regions_grid from frigate.version import VERSION from frigate.video import capture_camera, track_camera @@ -126,6 +127,9 @@ class FrigateApp: config_file = config_file_yaml save_default_config(config_file) + # check if the config file needs to be migrated + migrate_frigate_config(config_file) + user_config = FrigateConfig.parse_file(config_file) self.config = user_config.runtime_config(self.plus_api) @@ -200,9 +204,6 @@ class FrigateApp: logging.getLogger("ws4py").setLevel("ERROR") def init_queues(self) -> None: - # Queues for clip processing - self.event_processed_queue: Queue = mp.Queue() - # Queue for cameras to push tracked objects to self.detected_frames_queue: Queue = mp.Queue( maxsize=sum(camera.enabled for camera in self.config.cameras.values()) * 2 @@ -420,7 +421,6 @@ class FrigateApp: self.config, self.dispatcher, self.detected_frames_queue, - self.event_processed_queue, self.ptz_autotracker_thread, self.stop_event, ) @@ -517,7 +517,6 @@ class FrigateApp: def start_event_processor(self) -> None: self.event_processor = EventProcessor( self.config, - self.event_processed_queue, self.timeline_queue, self.stop_event, ) @@ -672,6 +671,14 @@ class FrigateApp: logger.info("Stopping...") self.stop_event.set() + # set an end_time on entries without an end_time before exiting + Event.update(end_time=datetime.datetime.now().timestamp()).where( + Event.end_time == None + ).execute() + ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where( + ReviewSegment.end_time == None + ).execute() + # Stop Communicators self.inter_process_communicator.stop() self.inter_config_updater.stop() @@ -704,7 +711,6 @@ class FrigateApp: shm.unlink() for queue in [ - self.event_processed_queue, self.detected_frames_queue, self.log_queue, ]: diff --git a/frigate/comms/detections_updater.py b/frigate/comms/detections_updater.py index fa4f56252..37da1586e 100644 --- a/frigate/comms/detections_updater.py +++ b/frigate/comms/detections_updater.py @@ -8,7 +8,7 @@ import zmq SOCKET_CONTROL = "inproc://control.detections_updater" SOCKET_PUB = "ipc:///tmp/cache/detect_pub" -SOCKET_SUB = "ipc:///tmp/cache/detect_sun" +SOCKET_SUB = "ipc:///tmp/cache/detect_sub" class DetectionTypeEnum(str, Enum): diff --git a/frigate/comms/events_updater.py b/frigate/comms/events_updater.py index cb18667a1..29207df33 100644 --- a/frigate/comms/events_updater.py +++ b/frigate/comms/events_updater.py @@ -5,6 +5,7 @@ import zmq from frigate.events.types import EventStateEnum, EventTypeEnum SOCKET_PUSH_PULL = "ipc:///tmp/cache/events" +SOCKET_PUSH_PULL_END = "ipc:///tmp/cache/events_ended" class EventUpdatePublisher: @@ -37,7 +38,53 @@ class EventUpdateSubscriber: def check_for_update( self, timeout=1 ) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]: - """Returns updated config or None if no update.""" + """Returns events or None if no update.""" + try: + has_update, _, _ = zmq.select([self.socket], [], [], timeout) + + if has_update: + return self.socket.recv_pyobj() + except zmq.ZMQError: + pass + + return None + + def stop(self) -> None: + self.socket.close() + self.context.destroy() + + +class EventEndPublisher: + """Publishes events that have ended.""" + + def __init__(self) -> None: + self.context = zmq.Context() + self.socket = self.context.socket(zmq.PUSH) + self.socket.connect(SOCKET_PUSH_PULL_END) + + def publish( + self, payload: tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]] + ) -> None: + """There is no communication back to the processes.""" + self.socket.send_pyobj(payload) + + def stop(self) -> None: + self.socket.close() + self.context.destroy() + + +class EventEndSubscriber: + """Receives events that have ended.""" + + def __init__(self) -> None: + self.context = zmq.Context() + self.socket = self.context.socket(zmq.PULL) + self.socket.bind(SOCKET_PUSH_PULL_END) + + def check_for_update( + self, timeout=1 + ) -> tuple[EventTypeEnum, EventStateEnum, str, dict[str, any]]: + """Returns events ended or None if no update.""" try: has_update, _, _ = zmq.select([self.socket], [], [], timeout) diff --git a/frigate/config.py b/frigate/config.py index 9317ae54c..ebb471028 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -245,10 +245,6 @@ class EventsConfig(FrigateBaseModel): default=5, title="Seconds to retain before event starts.", le=MAX_PRE_CAPTURE ) post_capture: int = Field(default=5, title="Seconds to retain after event ends.") - required_zones: List[str] = Field( - default_factory=list, - title="List of required zones to be entered in order to save the event.", - ) objects: Optional[List[str]] = Field( None, title="List of objects to be detected in order to save the event.", @@ -354,6 +350,34 @@ class RuntimeMotionConfig(MotionConfig): frame_shape = config.get("frame_shape", (1, 1)) mask = config.get("mask", "") + + # masks and zones are saved as relative coordinates + # we know if any points are > 1 then it is using the + # old native resolution coordinates + if mask: + if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")): + relative_masks = [] + for m in mask: + points = m.split(",") + relative_masks.append( + ",".join( + [ + f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" + for i in range(0, len(points), 2) + ] + ) + ) + + mask = relative_masks + elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")): + points = mask.split(",") + mask = ",".join( + [ + f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" + for i in range(0, len(points), 2) + ] + ) + config["raw_mask"] = mask if mask: @@ -484,11 +508,40 @@ class RuntimeFilterConfig(FilterConfig): raw_mask: Optional[Union[str, List[str]]] = None def __init__(self, **config): + frame_shape = config.get("frame_shape", (1, 1)) mask = config.get("mask") + + # masks and zones are saved as relative coordinates + # we know if any points are > 1 then it is using the + # old native resolution coordinates + if mask: + if isinstance(mask, list) and any(x > "1.0" for x in mask[0].split(",")): + relative_masks = [] + for m in mask: + points = m.split(",") + relative_masks.append( + ",".join( + [ + f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" + for i in range(0, len(points), 2) + ] + ) + ) + + mask = relative_masks + elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")): + points = mask.split(",") + mask = ",".join( + [ + f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" + for i in range(0, len(points), 2) + ] + ) + config["raw_mask"] = mask if mask is not None: - config["mask"] = create_mask(config.get("frame_shape", (1, 1)), mask) + config["mask"] = create_mask(frame_shape, mask) super().__init__(**config) @@ -539,30 +592,106 @@ class ZoneConfig(BaseModel): super().__init__(**config) self._color = config.get("color", (0, 0, 0)) - coordinates = config["coordinates"] + self._contour = config.get("contour", np.array([])) + def generate_contour(self, frame_shape: tuple[int, int]): + coordinates = self.coordinates + + # masks and zones are saved as relative coordinates + # we know if any points are > 1 then it is using the + # old native resolution coordinates if isinstance(coordinates, list): + explicit = any(p.split(",")[0] > "1.0" for p in coordinates) self._contour = np.array( - [[int(p.split(",")[0]), int(p.split(",")[1])] for p in coordinates] + [ + ( + [int(p.split(",")[0]), int(p.split(",")[1])] + if explicit + else [ + int(float(p.split(",")[0]) * frame_shape[1]), + int(float(p.split(",")[1]) * frame_shape[0]), + ] + ) + for p in coordinates + ] ) + + if explicit: + self.coordinates = ",".join( + [ + f'{round(int(p.split(",")[0]) / frame_shape[1], 3)},{round(int(p.split(",")[1]) / frame_shape[0], 3)}' + for p in coordinates + ] + ) elif isinstance(coordinates, str): points = coordinates.split(",") + explicit = any(p > "1.0" for p in points) self._contour = np.array( - [[int(points[i]), int(points[i + 1])] for i in range(0, len(points), 2)] + [ + ( + [int(points[i]), int(points[i + 1])] + if explicit + else [ + int(float(points[i]) * frame_shape[1]), + int(float(points[i + 1]) * frame_shape[0]), + ] + ) + for i in range(0, len(points), 2) + ] ) + + if explicit: + self.coordinates = ",".join( + [ + f"{round(int(points[i]) / frame_shape[1], 3)},{round(int(points[i + 1]) / frame_shape[0], 3)}" + for i in range(0, len(points), 2) + ] + ) else: self._contour = np.array([]) class ObjectConfig(FrigateBaseModel): track: List[str] = Field(default=DEFAULT_TRACKED_OBJECTS, title="Objects to track.") - alert: List[str] = Field( - default=DEFAULT_ALERT_OBJECTS, title="Objects to create alerts for." - ) filters: Dict[str, FilterConfig] = Field(default={}, title="Object filters.") mask: Union[str, List[str]] = Field(default="", title="Object mask.") +class AlertsConfig(FrigateBaseModel): + """Configure alerts""" + + labels: List[str] = Field( + default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for." + ) + required_zones: List[str] = Field( + default_factory=list, + title="List of required zones to be entered in order to save the event as an alert.", + ) + + +class DetectionsConfig(FrigateBaseModel): + """Configure detections""" + + labels: Optional[List[str]] = Field( + default=None, title="Labels to create detections for." + ) + required_zones: List[str] = Field( + default_factory=list, + title="List of required zones to be entered in order to save the event as a detection.", + ) + + +class ReviewConfig(FrigateBaseModel): + """Configure reviews""" + + alerts: AlertsConfig = Field( + default_factory=AlertsConfig, title="Review alerts config." + ) + detections: DetectionsConfig = Field( + default_factory=DetectionsConfig, title="Review detections config." + ) + + class AudioConfig(FrigateBaseModel): enabled: bool = Field(default=False, title="Enable audio events.") max_not_heard: int = Field( @@ -841,6 +970,9 @@ class CameraConfig(FrigateBaseModel): objects: ObjectConfig = Field( default_factory=ObjectConfig, title="Object configuration." ) + review: ReviewConfig = Field( + default_factory=ReviewConfig, title="Review configuration." + ) audio: AudioConfig = Field( default_factory=AudioConfig, title="Audio events configuration." ) @@ -1162,6 +1294,9 @@ class FrigateConfig(FrigateBaseModel): objects: ObjectConfig = Field( default_factory=ObjectConfig, title="Global object configuration." ) + review: ReviewConfig = Field( + default_factory=ReviewConfig, title="Review configuration." + ) audio: AudioConfig = Field( default_factory=AudioConfig, title="Global Audio events configuration." ) @@ -1209,6 +1344,7 @@ class FrigateConfig(FrigateBaseModel): "snapshots": ..., "live": ..., "objects": ..., + "review": ..., "motion": ..., "detect": ..., "ffmpeg": ..., @@ -1346,6 +1482,11 @@ class FrigateConfig(FrigateBaseModel): ) camera_config.motion.enabled_in_config = camera_config.motion.enabled + # generate zone contours + if len(camera_config.zones) > 0: + for zone in camera_config.zones.values(): + zone.generate_contour(camera_config.frame_shape) + # Set live view stream if none is set if not camera_config.live.stream_name: camera_config.live.stream_name = name diff --git a/frigate/const.py b/frigate/const.py index 9b65fe5c9..28ab83c8a 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -39,6 +39,10 @@ AUDIO_MAX_BIT_RANGE = 32768.0 AUDIO_SAMPLE_RATE = 16000 AUDIO_MIN_CONFIDENCE = 0.5 +# DB Consts + +MAX_WAL_SIZE = 10 # MB + # Ffmpeg Presets FFMPEG_HWACCEL_NVIDIA = "preset-nvidia" diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index 720022e05..0ef3ffaac 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -1,11 +1,10 @@ -import datetime import logging import threading from multiprocessing import Queue from multiprocessing.synchronize import Event as MpEvent from typing import Dict -from frigate.comms.events_updater import EventUpdateSubscriber +from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber from frigate.config import EventsConfig, FrigateConfig from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.models import Event @@ -52,19 +51,18 @@ class EventProcessor(threading.Thread): def __init__( self, config: FrigateConfig, - event_processed_queue: Queue, timeline_queue: Queue, stop_event: MpEvent, ): threading.Thread.__init__(self) self.name = "event_processor" self.config = config - self.event_processed_queue = event_processed_queue self.timeline_queue = timeline_queue self.events_in_process: Dict[str, Event] = {} self.stop_event = stop_event self.event_receiver = EventUpdateSubscriber() + self.event_end_publisher = EventEndPublisher() def run(self) -> None: # set an end_time on events without an end_time on startup @@ -113,11 +111,8 @@ class EventProcessor(threading.Thread): self.handle_external_detection(event_type, event_data) - # set an end_time on events without an end_time before exiting - Event.update(end_time=datetime.datetime.now().timestamp()).where( - Event.end_time == None - ).execute() self.event_receiver.stop() + self.event_end_publisher.stop() logger.info("Exiting event processor...") def handle_object_detection( @@ -242,7 +237,7 @@ class EventProcessor(threading.Thread): if event_type == EventStateEnum.end: del self.events_in_process[event_data["id"]] - self.event_processed_queue.put((event_data["id"], camera)) + self.event_end_publisher.publish((event_data["id"], camera)) def handle_external_detection( self, event_type: EventStateEnum, event_data: Event diff --git a/frigate/object_processing.py b/frigate/object_processing.py index c5e8101dc..9da0e2b25 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -6,6 +6,7 @@ import os import queue import threading from collections import Counter, defaultdict +from multiprocessing.synchronize import Event as MpEvent from statistics import median from typing import Callable @@ -14,7 +15,7 @@ import numpy as np from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.dispatcher import Dispatcher -from frigate.comms.events_updater import EventUpdatePublisher +from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher from frigate.config import ( CameraConfig, FrigateConfig, @@ -827,7 +828,6 @@ class TrackedObjectProcessor(threading.Thread): config: FrigateConfig, dispatcher: Dispatcher, tracked_objects_queue, - event_processed_queue, ptz_autotracker_thread, stop_event, ): @@ -836,14 +836,14 @@ class TrackedObjectProcessor(threading.Thread): self.config = config self.dispatcher = dispatcher self.tracked_objects_queue = tracked_objects_queue - self.event_processed_queue = event_processed_queue - self.stop_event = stop_event + self.stop_event: MpEvent = stop_event self.camera_states: dict[str, CameraState] = {} self.frame_manager = SharedMemoryFrameManager() self.last_motion_detected: dict[str, float] = {} self.ptz_autotracker_thread = ptz_autotracker_thread self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) self.event_sender = EventUpdatePublisher() + self.event_end_subscriber = EventEndSubscriber() def start(camera, obj: TrackedObject, current_frame_time): self.event_sender.publish( @@ -1007,7 +1007,7 @@ class TrackedObjectProcessor(threading.Thread): return True - def should_retain_recording(self, camera, obj: TrackedObject): + def should_retain_recording(self, camera: str, obj: TrackedObject): if obj.false_positive: return False @@ -1022,7 +1022,11 @@ class TrackedObjectProcessor(threading.Thread): return False # If there are required zones and there is no overlap - required_zones = record_config.events.required_zones + review_config = self.config.cameras[camera].review + required_zones = ( + review_config.alerts.required_zones + + review_config.detections.required_zones + ) if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones): logger.debug( f"Not creating clip for {obj.obj_data['id']} because it did not enter required zones" @@ -1215,10 +1219,16 @@ class TrackedObjectProcessor(threading.Thread): ) # cleanup event finished queue - while not self.event_processed_queue.empty(): - event_id, camera = self.event_processed_queue.get() + while not self.stop_event.is_set(): + update = self.event_end_subscriber.check_for_update(timeout=0.01) + + if not update: + break + + event_id, camera = update self.camera_states[camera].finished(event_id) self.detection_publisher.stop() self.event_sender.stop() + self.event_end_subscriber.stop() logger.info("Exiting object processor...") diff --git a/frigate/record/cleanup.py b/frigate/record/cleanup.py index 86f1e63e1..c45ea032d 100644 --- a/frigate/record/cleanup.py +++ b/frigate/record/cleanup.py @@ -3,12 +3,15 @@ import datetime import itertools import logging +import os import threading from multiprocessing.synchronize import Event as MpEvent from pathlib import Path +from playhouse.sqlite_ext import SqliteExtDatabase + from frigate.config import CameraConfig, FrigateConfig, RetainModeEnum -from frigate.const import CACHE_DIR, RECORD_DIR +from frigate.const import CACHE_DIR, MAX_WAL_SIZE, RECORD_DIR from frigate.models import Event, Previews, Recordings, ReviewSegment from frigate.record.util import remove_empty_directories, sync_recordings from frigate.util.builtin import clear_and_unlink, get_tomorrow_at_time @@ -33,6 +36,23 @@ class RecordingCleanup(threading.Thread): logger.debug("Deleting tmp clip.") clear_and_unlink(p) + def truncate_wal(self) -> None: + """check if the WAL needs to be manually truncated.""" + + # by default the WAL should be check-pointed automatically + # however, high levels of activity can prevent an opportunity + # for the checkpoint to be finished which means the WAL will grow + # without bound + + # with auto checkpoint most users should never hit this + + if ( + os.stat(f"{self.config.database.path}-wal").st_size / (1024 * 1024) + ) > MAX_WAL_SIZE: + db = SqliteExtDatabase(self.config.database.path) + db.execute_sql("PRAGMA wal_checkpoint(TRUNCATE);") + db.close() + def expire_existing_camera_recordings( self, expire_date: float, config: CameraConfig, events: Event ) -> None: @@ -328,3 +348,4 @@ class RecordingCleanup(threading.Thread): if counter == 0: self.expire_recordings() remove_empty_directories(RECORD_DIR) + self.truncate_wal() diff --git a/frigate/record/export.py b/frigate/record/export.py index 2a5b46fa3..238d7b117 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -103,14 +103,14 @@ class RecordingExporter(threading.Thread): if self.playback_factor == PlaybackFactorEnum.realtime: ffmpeg_cmd = ( - f"ffmpeg -hide_banner {ffmpeg_input} -c copy {file_path}" + f"ffmpeg -hide_banner {ffmpeg_input} -c copy -movflags +faststart {file_path}" ).split(" ") elif self.playback_factor == PlaybackFactorEnum.timelapse_25x: ffmpeg_cmd = ( parse_preset_hardware_acceleration_encode( self.config.ffmpeg.hwaccel_args, f"{TIMELAPSE_DATA_INPUT_ARGS} {ffmpeg_input}", - f"{self.config.cameras[self.camera].record.export.timelapse_args} {file_path}", + f"{self.config.cameras[self.camera].record.export.timelapse_args} -movflags +faststart {file_path}", EncodeTypeEnum.timelapse, ) ).split(" ") diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 5cbd3a476..ebc03a35e 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -32,13 +32,11 @@ THUMB_WIDTH = 320 THRESHOLD_ALERT_ACTIVITY = 120 THRESHOLD_DETECTION_ACTIVITY = 30 -THRESHOLD_MOTION_ACTIVITY = 30 class SeverityEnum(str, Enum): alert = "alert" detection = "detection" - signification_motion = "significant_motion" class PendingReviewSegment: @@ -50,7 +48,6 @@ class PendingReviewSegment: detections: dict[str, str], zones: set[str] = set(), audio: set[str] = set(), - motion: list[int] = [], ): rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) self.id = f"{frame_time}-{rand_id}" @@ -60,12 +57,12 @@ class PendingReviewSegment: self.detections = detections self.zones = zones self.audio = audio - self.sig_motion_areas = motion self.last_update = frame_time # thumbnail self.frame = np.zeros((THUMB_HEIGHT * 3 // 2, THUMB_WIDTH), np.uint8) self.frame_active_count = 0 + self.frame_path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg") def update_frame( self, camera_config: CameraConfig, frame, objects: list[TrackedObject] @@ -98,25 +95,24 @@ class PendingReviewSegment: color_frame, dsize=(width, THUMB_HEIGHT), interpolation=cv2.INTER_AREA ) - def end(self) -> dict: - path = os.path.join(CLIPS_DIR, f"thumb-{self.camera}-{self.id}.jpg") - if self.frame is not None: - cv2.imwrite(path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60]) + cv2.imwrite( + self.frame_path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60] + ) + def get_data(self, ended: bool) -> dict: return { ReviewSegment.id: self.id, ReviewSegment.camera: self.camera, ReviewSegment.start_time: self.start_time, - ReviewSegment.end_time: self.last_update, + ReviewSegment.end_time: self.last_update if ended else None, ReviewSegment.severity: self.severity.value, - ReviewSegment.thumb_path: path, + ReviewSegment.thumb_path: self.frame_path, ReviewSegment.data: { "detections": list(set(self.detections.keys())), "objects": list(set(self.detections.values())), "zones": list(self.zones), "audio": list(self.audio), - "significant_motion_areas": self.sig_motion_areas, }, } @@ -141,9 +137,20 @@ class ReviewSegmentMaintainer(threading.Thread): self.stop_event = stop_event + def update_segment(self, segment: PendingReviewSegment) -> None: + """Update segment.""" + seg_data = segment.get_data(ended=False) + self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data) + self.requestor.send_data( + "reviews", + json.dumps( + {"type": "update", "review": {k.name: v for k, v in seg_data.items()}} + ), + ) + def end_segment(self, segment: PendingReviewSegment) -> None: """End segment.""" - seg_data = segment.end() + seg_data = segment.get_data(ended=True) self.requestor.send_data(UPSERT_REVIEW_SEGMENT, seg_data) self.requestor.send_data( "reviews", @@ -158,7 +165,6 @@ class ReviewSegmentMaintainer(threading.Thread): segment: PendingReviewSegment, frame_time: float, objects: list[TrackedObject], - motion: list, ) -> None: """Validate if existing review segment should continue.""" camera_config = self.config.cameras[segment.camera] @@ -168,18 +174,6 @@ class ReviewSegmentMaintainer(threading.Thread): if frame_time > segment.last_update: segment.last_update = frame_time - # update type for this segment now that active objects are detected - if segment.severity == SeverityEnum.signification_motion: - segment.severity = SeverityEnum.detection - - if len(active_objects) > segment.frame_active_count: - frame_id = f"{camera_config.name}{frame_time}" - yuv_frame = self.frame_manager.get( - frame_id, camera_config.frame_shape_yuv - ) - segment.update_frame(camera_config, yuv_frame, active_objects) - self.frame_manager.close(frame_id) - for object in active_objects: if not object["sub_label"]: segment.detections[object["id"]] = object["label"] @@ -188,24 +182,38 @@ class ReviewSegmentMaintainer(threading.Thread): else: segment.detections[object["id"]] = f'{object["label"]}-verified' - # if object is alert label and has qualified for recording + # if object is alert label + # and has entered required zones or required zones is not set # mark this review as alert if ( - segment.severity == SeverityEnum.detection - and object["has_clip"] - and object["label"] in camera_config.objects.alert + segment.severity != SeverityEnum.alert + and object["label"] in camera_config.review.alerts.labels + and ( + not camera_config.review.alerts.required_zones + or ( + len(object["current_zones"]) > 0 + and set(object["current_zones"]) + & set(camera_config.review.alerts.required_zones) + ) + ) ): segment.severity = SeverityEnum.alert # keep zones up to date if len(object["current_zones"]) > 0: segment.zones.update(object["current_zones"]) - elif ( - segment.severity == SeverityEnum.signification_motion - and len(motion) >= THRESHOLD_MOTION_ACTIVITY - ): - if frame_time > segment.last_update: - segment.last_update = frame_time + + if len(active_objects) > segment.frame_active_count: + try: + frame_id = f"{camera_config.name}{frame_time}" + yuv_frame = self.frame_manager.get( + frame_id, camera_config.frame_shape_yuv + ) + segment.update_frame(camera_config, yuv_frame, active_objects) + self.frame_manager.close(frame_id) + self.update_segment(segment) + except FileNotFoundError: + return else: if segment.severity == SeverityEnum.alert and frame_time > ( segment.last_update + THRESHOLD_ALERT_ACTIVITY @@ -219,7 +227,6 @@ class ReviewSegmentMaintainer(threading.Thread): camera: str, frame_time: float, objects: list[TrackedObject], - motion: list, ) -> None: """Check if a new review segment should be created.""" camera_config = self.config.cameras[camera] @@ -229,15 +236,9 @@ class ReviewSegmentMaintainer(threading.Thread): has_sig_object = False detections: dict[str, str] = {} zones: set = set() + severity = None for object in active_objects: - if ( - not has_sig_object - and object["has_clip"] - and object["label"] in camera_config.objects.alert - ): - has_sig_object = True - if not object["sub_label"]: detections[object["id"]] = object["label"] elif object["sub_label"][0] in ALL_ATTRIBUTE_LABELS: @@ -245,34 +246,68 @@ class ReviewSegmentMaintainer(threading.Thread): else: detections[object["id"]] = f'{object["label"]}-verified' + # if object is alert label + # and has entered required zones or required zones is not set + # mark this review as alert + if ( + severity != SeverityEnum.alert + and object["label"] in camera_config.review.alerts.labels + and ( + not camera_config.review.alerts.required_zones + or ( + len(object["current_zones"]) > 0 + and set(object["current_zones"]) + & set(camera_config.review.alerts.required_zones) + ) + ) + ): + severity = SeverityEnum.alert + + # if object is detection label + # and review is not already a detection or alert + # and has entered required zones or required zones is not set + # mark this review as alert + if ( + not severity + and ( + not camera_config.review.detections.labels + or object["label"] in (camera_config.review.detections.labels) + ) + and ( + not camera_config.review.detections.required_zones + or ( + len(object["current_zones"]) > 0 + and set(object["current_zones"]) + & set(camera_config.review.detections.required_zones) + ) + ) + ): + severity = SeverityEnum.detection + zones.update(object["current_zones"]) - self.active_review_segments[camera] = PendingReviewSegment( - camera, - frame_time, - SeverityEnum.alert if has_sig_object else SeverityEnum.detection, - detections, - audio=set(), - zones=zones, - motion=[], - ) + if severity: + self.active_review_segments[camera] = PendingReviewSegment( + camera, + frame_time, + SeverityEnum.alert if has_sig_object else SeverityEnum.detection, + detections, + audio=set(), + zones=zones, + ) - frame_id = f"{camera_config.name}{frame_time}" - yuv_frame = self.frame_manager.get(frame_id, camera_config.frame_shape_yuv) - self.active_review_segments[camera].update_frame( - camera_config, yuv_frame, active_objects - ) - self.frame_manager.close(frame_id) - elif len(motion) >= 20: - self.active_review_segments[camera] = PendingReviewSegment( - camera, - frame_time, - SeverityEnum.signification_motion, - detections={}, - audio=set(), - motion=motion, - zones=set(), - ) + try: + frame_id = f"{camera_config.name}{frame_time}" + yuv_frame = self.frame_manager.get( + frame_id, camera_config.frame_shape_yuv + ) + self.active_review_segments[camera].update_frame( + camera_config, yuv_frame, active_objects + ) + self.frame_manager.close(frame_id) + self.update_segment(self.active_review_segments[camera]) + except FileNotFoundError: + return def run(self) -> None: while not self.stop_event.is_set(): @@ -330,13 +365,22 @@ class ReviewSegmentMaintainer(threading.Thread): current_segment, frame_time, current_tracked_objects, - motion_boxes, ) elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0: + camera_config = self.config.cameras[camera] + if frame_time > current_segment.last_update: current_segment.last_update = frame_time - current_segment.audio.update(audio_detections) + for audio in audio_detections: + if audio in camera_config.review.alerts.labels: + current_segment.audio.add(audio) + current_segment.severity = SeverityEnum.alert + elif ( + not camera_config.review.detections.labels + or audio in camera_config.review.detections.labels + ): + current_segment.audio.add(audio) elif topic == DetectionTypeEnum.api: if manual_info["state"] == ManualEventState.complete: current_segment.detections[manual_info["event_id"]] = ( @@ -364,18 +408,35 @@ class ReviewSegmentMaintainer(threading.Thread): camera, frame_time, current_tracked_objects, - motion_boxes, ) elif topic == DetectionTypeEnum.audio and len(audio_detections) > 0: - self.active_review_segments[camera] = PendingReviewSegment( - camera, - frame_time, - SeverityEnum.detection, - {}, - set(), - set(audio_detections), - [], - ) + severity = None + + camera_config = self.config.cameras[camera] + detections = set() + + for audio in audio_detections: + if audio in camera_config.review.alerts.labels: + detections.add(audio) + severity = SeverityEnum.alert + elif ( + not camera_config.review.detections.labels + or audio in camera_config.review.detections.labels + ): + detections.add(audio) + + if not severity: + severity = SeverityEnum.detection + + if severity: + self.active_review_segments[camera] = PendingReviewSegment( + camera, + frame_time, + severity, + {}, + set(), + detections, + ) elif topic == DetectionTypeEnum.api: self.active_review_segments[camera] = PendingReviewSegment( camera, @@ -384,7 +445,6 @@ class ReviewSegmentMaintainer(threading.Thread): {manual_info["event_id"]: manual_info["label"]}, set(), set(), - [], ) if manual_info["state"] == ManualEventState.start: @@ -398,6 +458,11 @@ class ReviewSegmentMaintainer(threading.Thread): "end_time" ] + self.config_subscriber.stop() + self.requestor.stop() + self.detection_subscriber.stop() + logger.info("Exiting review maintainer...") + def get_active_objects( frame_time: float, camera_config: CameraConfig, all_objects: list[TrackedObject] @@ -406,8 +471,16 @@ def get_active_objects( return [ o for o in all_objects - if o["motionless_count"] < camera_config.detect.stationary.threshold - and o["position_changes"] > 0 - and o["frame_time"] == frame_time - and not o["false_positive"] + if o["motionless_count"] + < camera_config.detect.stationary.threshold # no stationary objects + and o["position_changes"] > 0 # object must have moved at least once + and o["frame_time"] == frame_time # object must be detected in this frame + and not o["false_positive"] # object must not be a false positive + and ( + o["label"] in camera_config.review.alerts.labels + or ( + not camera_config.review.detections.labels + or o["label"] in camera_config.review.detections.labels + ) + ) # object must be in the alerts or detections label list ] diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index 949438540..be935d431 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -64,7 +64,7 @@ class TestConfig(unittest.TestCase): def test_config_class(self): frigate_config = FrigateConfig(**self.minimal) - assert self.minimal == frigate_config.dict(exclude_unset=True) + assert self.minimal == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "cpu" in runtime_config.detectors.keys() @@ -157,7 +157,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "dog" in runtime_config.cameras["back"].objects.track @@ -183,7 +183,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert not runtime_config.cameras["back"].birdseye.enabled @@ -209,7 +209,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].birdseye.enabled @@ -234,7 +234,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].birdseye.enabled @@ -263,7 +263,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "cat" in runtime_config.cameras["back"].objects.track @@ -288,7 +288,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "dog" in runtime_config.cameras["back"].objects.filters @@ -316,7 +316,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "dog" in runtime_config.cameras["back"].objects.filters @@ -345,7 +345,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "dog" in runtime_config.cameras["back"].objects.filters @@ -375,7 +375,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() back_camera = runtime_config.cameras["back"] @@ -383,6 +383,55 @@ class TestConfig(unittest.TestCase): assert len(back_camera.objects.filters["dog"].raw_mask) == 2 assert len(back_camera.objects.filters["person"].raw_mask) == 1 + def test_motion_mask_relative_matches_explicit(self): + config = { + "mqtt": {"host": "mqtt"}, + "record": { + "events": {"retain": {"default": 20, "objects": {"person": 30}}} + }, + "cameras": { + "explicit": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 400, + "width": 800, + "fps": 5, + }, + "motion": { + "mask": [ + "0,0,200,100,600,300,800,400", + ] + }, + }, + "relative": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 400, + "width": 800, + "fps": 5, + }, + "motion": { + "mask": [ + "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0", + ] + }, + }, + }, + } + frigate_config = FrigateConfig(**config).runtime_config() + assert np.array_equal( + frigate_config.cameras["explicit"].motion.mask, + frigate_config.cameras["relative"].motion.mask, + ) + def test_default_input_args(self): config = { "mqtt": {"host": "mqtt"}, @@ -406,7 +455,7 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "-rtsp_transport" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"] @@ -435,7 +484,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"] @@ -465,7 +514,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"] @@ -500,7 +549,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "-re" in runtime_config.cameras["back"].ffmpeg_cmds[0]["cmd"] @@ -530,7 +579,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert ( @@ -608,7 +657,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert isinstance( @@ -616,6 +665,41 @@ class TestConfig(unittest.TestCase): ) assert runtime_config.cameras["back"].zones["test"].color != (0, 0, 0) + def test_zone_relative_matches_explicit(self): + config = { + "mqtt": {"host": "mqtt"}, + "record": { + "events": {"retain": {"default": 20, "objects": {"person": 30}}} + }, + "cameras": { + "back": { + "ffmpeg": { + "inputs": [ + {"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]} + ] + }, + "detect": { + "height": 400, + "width": 800, + "fps": 5, + }, + "zones": { + "explicit": { + "coordinates": "0,0,200,100,600,300,800,400", + }, + "relative": { + "coordinates": "0.0,0.0,0.25,0.25,0.75,0.75,1.0,1.0", + }, + }, + } + }, + } + frigate_config = FrigateConfig(**config).runtime_config() + assert np.array_equal( + frigate_config.cameras["back"].zones["explicit"].contour, + frigate_config.cameras["back"].zones["relative"].contour, + ) + def test_clips_should_default_to_global_objects(self): config = { "mqtt": {"host": "mqtt"}, @@ -640,7 +724,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() back_camera = runtime_config.cameras["back"] @@ -671,7 +755,7 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() ffmpeg_cmds = runtime_config.cameras["back"].ffmpeg_cmds @@ -702,7 +786,7 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].detect.max_disappeared == 5 * 5 @@ -730,7 +814,7 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].motion.frame_height == 100 @@ -758,7 +842,7 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert round(runtime_config.cameras["back"].motion.contour_area) == 10 @@ -787,7 +871,7 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.model.merged_labelmap[7] == "truck" @@ -815,7 +899,7 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.model.merged_labelmap[0] == "person" @@ -844,7 +928,7 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.model.merged_labelmap[0] == "person" @@ -878,7 +962,7 @@ class TestConfig(unittest.TestCase): } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config(PlusApi()) assert runtime_config.model.merged_labelmap[0] == "amazon" @@ -1012,7 +1096,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].detect.max_disappeared == 1 @@ -1040,7 +1124,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].detect.max_disappeared == 25 @@ -1069,7 +1153,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].detect.max_disappeared == 1 @@ -1102,7 +1186,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].snapshots.enabled @@ -1130,7 +1214,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].snapshots.bounding_box @@ -1163,7 +1247,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].snapshots.bounding_box is False @@ -1193,7 +1277,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].live.quality == 4 @@ -1220,7 +1304,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].live.quality == 8 @@ -1251,7 +1335,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].live.quality == 7 @@ -1280,7 +1364,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].timestamp_style.position == "bl" @@ -1307,7 +1391,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].timestamp_style.position == "tl" @@ -1336,7 +1420,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].timestamp_style.position == "bl" @@ -1365,7 +1449,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert runtime_config.cameras["back"].snapshots.retain.default == 1.5 @@ -1505,7 +1589,7 @@ class TestConfig(unittest.TestCase): }, } frigate_config = FrigateConfig(**config) - assert config == frigate_config.dict(exclude_unset=True) + assert config == frigate_config.model_dump(exclude_unset=True) runtime_config = frigate_config.runtime_config() assert "dog" in runtime_config.cameras["back"].objects.filters diff --git a/frigate/util/config.py b/frigate/util/config.py new file mode 100644 index 000000000..46a1ea941 --- /dev/null +++ b/frigate/util/config.py @@ -0,0 +1,127 @@ +"""configuration utils.""" + +import logging +import os +import shutil + +from ruamel.yaml import YAML + +from frigate.const import CONFIG_DIR + +logger = logging.getLogger(__name__) + +CURRENT_CONFIG_VERSION = 0.14 + + +def migrate_frigate_config(config_file: str): + """handle migrating the frigate config.""" + logger.info("Checking if frigate config needs migration...") + version_file = os.path.join(CONFIG_DIR, ".version") + + if not os.path.isfile(version_file): + previous_version = 0.13 + else: + with open(version_file) as f: + try: + previous_version = float(f.readline()) + except Exception: + previous_version = 0.13 + + if previous_version == CURRENT_CONFIG_VERSION: + logger.info("frigate config does not need migration...") + return + + logger.info("copying config as backup...") + shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml")) + + yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + with open(config_file, "r") as f: + config: dict[str, dict[str, any]] = yaml.load(f) + + if previous_version < 0.14: + logger.info(f"Migrating frigate config from {previous_version} to 0.14...") + new_config = migrate_014(config) + with open(config_file, "w") as f: + yaml.dump(new_config, f) + previous_version = 0.14 + + with open(version_file, "w") as f: + f.write(str(CURRENT_CONFIG_VERSION)) + + logger.info("Finished frigate config migration...") + + +def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]: + """Handle migrating frigate config to 0.14""" + # migrate record.events.required_zones to review.alerts.required_zones + new_config = config.copy() + global_required_zones = ( + config.get("record", {}).get("events", {}).get("required_zones", []) + ) + + if global_required_zones: + # migrate to new review config + if not new_config.get("review"): + new_config["review"] = {} + + if not new_config["review"].get("alerts"): + new_config["review"]["alerts"] = {} + + if not new_config["review"]["alerts"].get("required_zones"): + new_config["review"]["alerts"]["required_zones"] = global_required_zones + + # remove record required zones config + del new_config["record"]["events"]["required_zones"] + + # remove record altogether if there is not other config + if not new_config["record"]["events"]: + del new_config["record"]["events"] + + if not new_config["record"]: + del new_config["record"] + + # remove rtmp + if new_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"): + del new_config["ffmpeg"]["output_args"]["rtmp"] + + if new_config.get("rtmp"): + del new_config["rtmp"] + + for name, camera in config.get("cameras", {}).items(): + camera_config: dict[str, dict[str, any]] = camera.copy() + required_zones = ( + camera_config.get("record", {}).get("events", {}).get("required_zones", []) + ) + + if required_zones: + # migrate to new review config + if not camera_config.get("review"): + camera_config["review"] = {} + + if not camera_config["review"].get("alerts"): + camera_config["review"]["alerts"] = {} + + if not camera_config["review"]["alerts"].get("required_zones"): + camera_config["review"]["alerts"]["required_zones"] = required_zones + + # remove record required zones config + del camera_config["record"]["events"]["required_zones"] + + # remove record altogether if there is not other config + if not camera_config["record"]["events"]: + del camera_config["record"]["events"] + + if not camera_config["record"]: + del camera_config["record"] + + # remove rtmp + if camera_config.get("ffmpeg", {}).get("output_args", {}).get("rtmp"): + del camera_config["ffmpeg"]["output_args"]["rtmp"] + + if camera_config.get("rtmp"): + del camera_config["rtmp"] + + new_config["cameras"][name] = camera_config + + return new_config diff --git a/frigate/util/image.py b/frigate/util/image.py index ef6c75ae4..67f8b5c22 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -727,9 +727,22 @@ def create_mask(frame_shape, mask): return mask_img -def add_mask(mask, mask_img): +def add_mask(mask: str, mask_img: np.ndarray): points = mask.split(",") + + # masks and zones are saved as relative coordinates + # we know if any points are > 1 then it is using the + # old native resolution coordinates + if any(x > "1.0" for x in points): + raise Exception("add mask expects relative coordinates only") + contour = np.array( - [[int(points[i]), int(points[i + 1])] for i in range(0, len(points), 2)] + [ + [ + int(float(points[i]) * mask_img.shape[1]), + int(float(points[i + 1]) * mask_img.shape[0]), + ] + for i in range(0, len(points), 2) + ] ) cv2.fillPoly(mask_img, pts=[contour], color=(0)) diff --git a/web/package-lock.json b/web/package-lock.json index b3d45d8b6..01003816c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -37,7 +37,7 @@ "hls.js": "^1.5.7", "idb-keyval": "^6.2.1", "immer": "^10.0.4", - "lucide-react": "^0.360.0", + "lucide-react": "^0.365.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.3.0", "react": "^18.2.0", @@ -45,14 +45,14 @@ "react-day-picker": "^8.9.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", - "react-hook-form": "^7.51.1", + "react-hook-form": "^7.51.2", "react-icons": "^5.0.1", "react-router-dom": "^6.22.3", "react-swipeable": "^7.0.1", "react-tracked": "^1.7.14", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", - "react-zoom-pan-pinch": "^3.4.3", + "react-zoom-pan-pinch": "^3.4.4", "recoil": "^0.7.7", "scroll-into-view-if-needed": "^3.1.0", "sonner": "^1.4.41", @@ -68,20 +68,20 @@ "devDependencies": { "@tailwindcss/forms": "^0.5.7", "@testing-library/jest-dom": "^6.1.5", - "@types/node": "^20.11.30", - "@types/react": "^18.2.67", - "@types/react-dom": "^18.2.22", + "@types/node": "^20.12.5", + "@types/react": "^18.2.74", + "@types/react-dom": "^18.2.24", "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", "@types/strftime": "^0.9.8", - "@typescript-eslint/eslint-plugin": "^7.3.1", - "@typescript-eslint/parser": "^7.3.1", + "@typescript-eslint/eslint-plugin": "^7.5.0", + "@typescript-eslint/parser": "^7.5.0", "@vitejs/plugin-react-swc": "^3.6.0", "@vitest/coverage-v8": "^1.4.0", "autoprefixer": "^10.4.19", "eslint": "^8.55.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-jest": "^27.6.0", + "eslint-plugin-jest": "^28.2.0", "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", @@ -89,12 +89,12 @@ "fake-indexeddb": "^5.0.2", "jest-websocket-mock": "^2.5.0", "jsdom": "^24.0.0", - "msw": "^2.2.9", + "msw": "^2.2.13", "postcss": "^8.4.38", "prettier": "^3.2.5", - "tailwindcss": "^3.4.1", - "typescript": "^5.4.3", - "vite": "^5.2.2", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.4", + "vite": "^5.2.8", "vitest": "^1.3.1" } }, @@ -841,9 +841,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.25.16", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.25.16.tgz", - "integrity": "sha512-8QC8JyKztvoGAdPgyZy49c9vSHHAZjHagwl4RY9E8carULk8ym3iTaiawrT1YoLF/qb449h48f71XDPgkUSOUg==", + "version": "0.26.15", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.26.15.tgz", + "integrity": "sha512-HM47Lu1YFmnYHKMBynFfjCp0U/yRskHj/8QEJW0CBEPOlw8Gkmjfll+S9b8M7V5CNDw2/ciRxjjnWeaCiblSIQ==", "dev": true, "dependencies": { "@open-draft/deferred-promise": "^2.2.0", @@ -2507,9 +2507,9 @@ } }, "node_modules/@types/node": { - "version": "20.11.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", - "integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==", + "version": "20.12.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.5.tgz", + "integrity": "sha512-BD+BjQ9LS/D8ST9p5uqBxghlN+S42iuNxjsUGjeZobe/ciXzk2qb1B6IXc6AnRLS+yFJRpN2IPEHMzwspfDJNw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -2522,20 +2522,19 @@ "devOptional": true }, "node_modules/@types/react": { - "version": "18.2.67", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.67.tgz", - "integrity": "sha512-vkIE2vTIMHQ/xL0rgmuoECBCkZFZeHr49HeWSc24AptMbNRo7pwSBvj73rlJJs9fGKj0koS+V7kQB1jHS0uCgw==", + "version": "18.2.74", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.74.tgz", + "integrity": "sha512-9AEqNZZyBx8OdZpxzQlaFEVCSFUM2YXJH46yPOiOpm078k6ZLOCcuAzGum/zK8YBwY+dbahVNbHrbgrAwIRlqw==", "devOptional": true, "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "18.2.22", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.22.tgz", - "integrity": "sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==", + "version": "18.2.24", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.24.tgz", + "integrity": "sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==", "devOptional": true, "dependencies": { "@types/react": "*" @@ -2560,12 +2559,6 @@ "@types/react": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "devOptional": true - }, "node_modules/@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", @@ -2591,16 +2584,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.3.1.tgz", - "integrity": "sha512-STEDMVQGww5lhCuNXVSQfbfuNII5E08QWkvAw5Qwf+bj2WT+JkG1uc+5/vXA3AOYMDHVOSpL+9rcbEUiHIm2dw==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.5.0.tgz", + "integrity": "sha512-HpqNTH8Du34nLxbKgVMGljZMG0rJd2O9ecvr2QLYp+7512ty1j42KnsFwspPXg1Vh8an9YImf6CokUBltisZFQ==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "7.3.1", - "@typescript-eslint/type-utils": "7.3.1", - "@typescript-eslint/utils": "7.3.1", - "@typescript-eslint/visitor-keys": "7.3.1", + "@typescript-eslint/scope-manager": "7.5.0", + "@typescript-eslint/type-utils": "7.5.0", + "@typescript-eslint/utils": "7.5.0", + "@typescript-eslint/visitor-keys": "7.5.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -2626,15 +2619,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.3.1.tgz", - "integrity": "sha512-Rq49+pq7viTRCH48XAbTA+wdLRrB/3sRq4Lpk0oGDm0VmnjBrAOVXH/Laalmwsv2VpekiEfVFwJYVk6/e8uvQw==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.5.0.tgz", + "integrity": "sha512-cj+XGhNujfD2/wzR1tabNsidnYRaFfEkcULdcIyVBYcXjBvBKOes+mpMBP7hMpOyk+gBcfXsrg4NBGAStQyxjQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.3.1", - "@typescript-eslint/types": "7.3.1", - "@typescript-eslint/typescript-estree": "7.3.1", - "@typescript-eslint/visitor-keys": "7.3.1", + "@typescript-eslint/scope-manager": "7.5.0", + "@typescript-eslint/types": "7.5.0", + "@typescript-eslint/typescript-estree": "7.5.0", + "@typescript-eslint/visitor-keys": "7.5.0", "debug": "^4.3.4" }, "engines": { @@ -2654,13 +2647,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.3.1.tgz", - "integrity": "sha512-fVS6fPxldsKY2nFvyT7IP78UO1/I2huG+AYu5AMjCT9wtl6JFiDnsv4uad4jQ0GTFzcUV5HShVeN96/17bTBag==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.5.0.tgz", + "integrity": "sha512-Z1r7uJY0MDeUlql9XJ6kRVgk/sP11sr3HKXn268HZyqL7i4cEfrdFuSSY/0tUqT37l5zT0tJOsuDP16kio85iA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.3.1", - "@typescript-eslint/visitor-keys": "7.3.1" + "@typescript-eslint/types": "7.5.0", + "@typescript-eslint/visitor-keys": "7.5.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2671,13 +2664,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.3.1.tgz", - "integrity": "sha512-iFhaysxFsMDQlzJn+vr3OrxN8NmdQkHks4WaqD4QBnt5hsq234wcYdyQ9uquzJJIDAj5W4wQne3yEsYA6OmXGw==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.5.0.tgz", + "integrity": "sha512-A021Rj33+G8mx2Dqh0nMO9GyjjIBK3MqgVgZ2qlKf6CJy51wY/lkkFqq3TqqnH34XyAHUkq27IjlUkWlQRpLHw==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.3.1", - "@typescript-eslint/utils": "7.3.1", + "@typescript-eslint/typescript-estree": "7.5.0", + "@typescript-eslint/utils": "7.5.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -2698,9 +2691,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.3.1.tgz", - "integrity": "sha512-2tUf3uWggBDl4S4183nivWQ2HqceOZh1U4hhu4p1tPiIJoRRXrab7Y+Y0p+dozYwZVvLPRI6r5wKe9kToF9FIw==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.5.0.tgz", + "integrity": "sha512-tv5B4IHeAdhR7uS4+bf8Ov3k793VEVHd45viRRkehIUZxm0WF82VPiLgHzA/Xl4TGPg1ZD49vfxBKFPecD5/mg==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -2711,13 +2704,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.3.1.tgz", - "integrity": "sha512-tLpuqM46LVkduWP7JO7yVoWshpJuJzxDOPYIVWUUZbW+4dBpgGeUdl/fQkhuV0A8eGnphYw3pp8d2EnvPOfxmQ==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.5.0.tgz", + "integrity": "sha512-YklQQfe0Rv2PZEueLTUffiQGKQneiIEKKnfIqPIOxgM9lKSZFCjT5Ad4VqRKj/U4+kQE3fa8YQpskViL7WjdPQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.3.1", - "@typescript-eslint/visitor-keys": "7.3.1", + "@typescript-eslint/types": "7.5.0", + "@typescript-eslint/visitor-keys": "7.5.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2763,17 +2756,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.3.1.tgz", - "integrity": "sha512-jIERm/6bYQ9HkynYlNZvXpzmXWZGhMbrOvq3jJzOSOlKXsVjrrolzWBjDW6/TvT5Q3WqaN4EkmcfdQwi9tDjBQ==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.5.0.tgz", + "integrity": "sha512-3vZl9u0R+/FLQcpy2EHyRGNqAS/ofJ3Ji8aebilfJe+fobK8+LbIFmrHciLVDxjDoONmufDcnVSF38KwMEOjzw==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.3.1", - "@typescript-eslint/types": "7.3.1", - "@typescript-eslint/typescript-estree": "7.3.1", + "@typescript-eslint/scope-manager": "7.5.0", + "@typescript-eslint/types": "7.5.0", + "@typescript-eslint/typescript-estree": "7.5.0", "semver": "^7.5.4" }, "engines": { @@ -2788,12 +2781,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.3.1.tgz", - "integrity": "sha512-9RMXwQF8knsZvfv9tdi+4D/j7dMG28X/wMJ8Jj6eOHyHWwDW4ngQJcqEczSsqIKKjFiLFr40Mnr7a5ulDD3vmw==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.5.0.tgz", + "integrity": "sha512-mcuHM/QircmA6O7fy6nn2w/3ditQkj+SgtOc8DW3uQ10Yfj42amm2i+6F2K4YAOPNNTmE6iM1ynM6lrSwdendA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.3.1", + "@typescript-eslint/types": "7.5.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -4014,19 +4007,19 @@ } }, "node_modules/eslint-plugin-jest": { - "version": "27.9.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.9.0.tgz", - "integrity": "sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==", + "version": "28.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.2.0.tgz", + "integrity": "sha512-yRDti/a+f+SMSmNTiT9/M/MzXGkitl8CfzUxnpoQcTyfq8gUrXMriVcWU36W1X6BZSUoyUCJrDAWWUA2N4hE5g==", "dev": true, "dependencies": { - "@typescript-eslint/utils": "^5.10.0" + "@typescript-eslint/utils": "^6.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^16.10.0 || ^18.12.0 || >=20.0.0" }, "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0 || ^7.0.0", - "eslint": "^7.0.0 || ^8.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0 || ^7.0.0", + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0", "jest": "*" }, "peerDependenciesMeta": { @@ -4039,16 +4032,16 @@ } }, "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/scope-manager": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0" + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", @@ -4056,12 +4049,12 @@ } }, "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/types": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", @@ -4069,21 +4062,22 @@ } }, "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/typescript-estree": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", @@ -4096,68 +4090,69 @@ } }, "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^7.0.0 || ^8.0.0" } }, "node_modules/eslint-plugin-jest/node_modules/@typescript-eslint/visitor-keys": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.62.0", - "eslint-visitor-keys": "^3.3.0" + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/eslint-plugin-jest/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "node_modules/eslint-plugin-jest/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" + "balanced-match": "^1.0.0" } }, - "node_modules/eslint-plugin-jest/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "node_modules/eslint-plugin-jest/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, "engines": { - "node": ">=4.0" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/eslint-plugin-prettier": { @@ -5284,9 +5279,9 @@ } }, "node_modules/lucide-react": { - "version": "0.360.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.360.0.tgz", - "integrity": "sha512-MskvbEsAhD2zxgx/I05vXq1cjFQXrmhL97YFIi4wSaKH793ZMvU/Com4d+DE7OB3QMmZig1fY1q94aTX5skozw==", + "version": "0.365.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.365.0.tgz", + "integrity": "sha512-sJYpPyyzGHI4B3pys+XSFnE4qtSWc68rFnDLxbNNKjkLST5XSx9DNn5+1Z3eFgFiw39PphNRiVBSVb+AL3oKwA==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } @@ -5528,9 +5523,9 @@ "dev": true }, "node_modules/msw": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.2.9.tgz", - "integrity": "sha512-MLIFufBe6m9c5rZKlmGl6jl1pjn7cTNiDGEgn5v2iVRs0mz+neE2r7lRyYNzvcp6FbdiUEIRp/Y2O2gRMjO8yQ==", + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.2.13.tgz", + "integrity": "sha512-ljFf1xZsU0b4zv1l7xzEmC6OZA6yD06hcx0H+dc8V0VypaP3HGYJa1rMLjQbBWl32ptGhcfwcPCWDB1wjmsftw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -5538,7 +5533,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.25.16", + "@mswjs/interceptors": "^0.26.14", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", @@ -6266,9 +6261,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.51.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.1.tgz", - "integrity": "sha512-ifnBjl+kW0ksINHd+8C/Gp6a4eZOdWyvRv0UBaByShwU8JbVx5hTcTWEcd5VdybvmPTATkVVXk9npXArHmo56w==", + "version": "7.51.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.2.tgz", + "integrity": "sha512-y++lwaWjtzDt/XNnyGDQy6goHskFualmDlf+jzEZvjvz6KWDf7EboL7pUvRCzPTJd0EOPpdekYaQLEvvG6m6HA==", "engines": { "node": ">=12.22.0" }, @@ -6447,9 +6442,9 @@ } }, "node_modules/react-zoom-pan-pinch": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.3.tgz", - "integrity": "sha512-x5MFlfAx2D6NTpZu8OISqc2nYn4p+YEaM1p21w7S/VE1wbVzK8vRzTo9Bj1ydufa649MuP7JBRM3vvj1RftFZw==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.4.4.tgz", + "integrity": "sha512-lGTu7D9lQpYEQ6sH+NSlLA7gicgKRW8j+D/4HO1AbSV2POvKRFzdWQ8eI0r3xmOsl4dYQcY+teV6MhULeg1xBw==", "engines": { "node": ">=8", "npm": ">=5" @@ -7200,9 +7195,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", - "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz", + "integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==", "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -7212,7 +7207,7 @@ "fast-glob": "^3.3.0", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.19.1", + "jiti": "^1.21.0", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", @@ -7392,27 +7387,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7447,9 +7421,9 @@ } }, "node_modules/typescript": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", - "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz", + "integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -7660,13 +7634,13 @@ } }, "node_modules/vite": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.2.tgz", - "integrity": "sha512-FWZbz0oSdLq5snUI0b6sULbz58iXFXdvkZfZWR/F0ZJuKTSPO7v72QPXt6KqYeMFb0yytNp6kZosxJ96Nr/wDQ==", + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz", + "integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==", "dev": true, "dependencies": { "esbuild": "^0.20.1", - "postcss": "^8.4.36", + "postcss": "^8.4.38", "rollup": "^4.13.0" }, "bin": { diff --git a/web/package.json b/web/package.json index 3ef40e655..6a5eccbc3 100644 --- a/web/package.json +++ b/web/package.json @@ -42,7 +42,7 @@ "hls.js": "^1.5.7", "idb-keyval": "^6.2.1", "immer": "^10.0.4", - "lucide-react": "^0.360.0", + "lucide-react": "^0.365.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.3.0", "react": "^18.2.0", @@ -50,14 +50,14 @@ "react-day-picker": "^8.9.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", - "react-hook-form": "^7.51.1", + "react-hook-form": "^7.51.2", "react-icons": "^5.0.1", "react-router-dom": "^6.22.3", "react-swipeable": "^7.0.1", "react-tracked": "^1.7.14", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", - "react-zoom-pan-pinch": "^3.4.3", + "react-zoom-pan-pinch": "^3.4.4", "recoil": "^0.7.7", "scroll-into-view-if-needed": "^3.1.0", "sonner": "^1.4.41", @@ -73,20 +73,20 @@ "devDependencies": { "@tailwindcss/forms": "^0.5.7", "@testing-library/jest-dom": "^6.1.5", - "@types/node": "^20.11.30", - "@types/react": "^18.2.67", - "@types/react-dom": "^18.2.22", + "@types/node": "^20.12.5", + "@types/react": "^18.2.74", + "@types/react-dom": "^18.2.24", "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", "@types/strftime": "^0.9.8", - "@typescript-eslint/eslint-plugin": "^7.3.1", - "@typescript-eslint/parser": "^7.3.1", + "@typescript-eslint/eslint-plugin": "^7.5.0", + "@typescript-eslint/parser": "^7.5.0", "@vitejs/plugin-react-swc": "^3.6.0", "@vitest/coverage-v8": "^1.4.0", "autoprefixer": "^10.4.19", "eslint": "^8.55.0", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-jest": "^27.6.0", + "eslint-plugin-jest": "^28.2.0", "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", @@ -94,12 +94,12 @@ "fake-indexeddb": "^5.0.2", "jest-websocket-mock": "^2.5.0", "jsdom": "^24.0.0", - "msw": "^2.2.9", + "msw": "^2.2.13", "postcss": "^8.4.38", "prettier": "^3.2.5", - "tailwindcss": "^3.4.1", - "typescript": "^5.4.3", - "vite": "^5.2.2", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.4", + "vite": "^5.2.8", "vitest": "^1.3.1" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 2f0853200..21ad0200e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { isDesktop, isMobile } from "react-device-detect"; import Statusbar from "./components/Statusbar"; import Bottombar from "./components/navigation/Bottombar"; import { Suspense, lazy } from "react"; +import { Redirect } from "./components/navigation/Redirect"; const Live = lazy(() => import("@/pages/Live")); const Events = lazy(() => import("@/pages/Events")); @@ -35,7 +36,8 @@ function App() { } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx index a210db508..8599ab9b6 100644 --- a/web/src/components/Statusbar.tsx +++ b/web/src/components/Statusbar.tsx @@ -32,7 +32,7 @@ export default function Statusbar() { const { potentialProblems } = useStats(stats); return ( -
+
{cpuPercent && (
diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index 03199e01a..85cfe3c3c 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -20,7 +20,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) { const navigate = useNavigate(); const onOpenReview = useCallback(() => { - navigate("events", { + navigate("review", { state: { severity: event.severity, recording: { diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 7431781ea..5ebd4be80 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -12,6 +12,7 @@ import { Input } from "../ui/input"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; type ExportProps = { + className: string; file: { name: string; }; @@ -19,7 +20,12 @@ type ExportProps = { onDelete: (file: string) => void; }; -export default function ExportCard({ file, onRename, onDelete }: ExportProps) { +export default function ExportCard({ + className, + file, + onRename, + onDelete, +}: ExportProps) { const videoRef = useRef(null); const [hovered, setHovered] = useState(false); const [playing, setPlaying] = useState(false); @@ -94,7 +100,7 @@ export default function ExportCard({ file, onRename, onDelete }: ExportProps) {
setHovered(true) : undefined } diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index 66710d6ff..941beaa2e 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -27,7 +27,9 @@ export default function ReviewCard({ config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p", ); const isSelected = useMemo( - () => event.start_time <= currentTime && event.end_time >= currentTime, + () => + event.start_time <= currentTime && + (event.end_time ?? Date.now() / 1000) >= currentTime, [event, currentTime], ); diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx index f690b9101..09a547c1b 100644 --- a/web/src/components/dynamic/NewReviewData.tsx +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -34,7 +34,6 @@ export default function NewReviewData({ ? "animate-in slide-in-from-top duration-500" : "invisible" } text-center mt-5 mx-auto bg-gray-400 text-white`} - variant="secondary" onClick={() => { pullLatestData(); if (contentRef.current) { diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index ec0ba89f4..e514667ed 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -131,7 +131,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { size="xs" onClick={() => setAddGroup(true)} > - + )}
@@ -253,7 +253,7 @@ function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) { {currentGroups.length > 0 && } {editState == "none" && ( + ); + const content = ( + + ); + + if (isMobile) { + return ( + + {trigger} + + {content} + + + ); + } + + return ( + + {trigger} + {content} + + ); +} + +type GeneralFilterContentProps = { + selectedLabels: LogSeverity[] | undefined; + updateLabelFilter: (labels: LogSeverity[] | undefined) => void; +}; +export function GeneralFilterContent({ + selectedLabels, + updateLabelFilter, +}: GeneralFilterContentProps) { + return ( + <> +
+
+ + { + if (isChecked) { + updateLabelFilter(undefined); + } + }} + /> +
+ +
+ {["debug", "info", "warning", "error"].map((item) => ( +
+ + { + if (isChecked) { + const updatedLabels = selectedLabels + ? [...selectedLabels] + : []; + + updatedLabels.push(item as LogSeverity); + updateLabelFilter(updatedLabels); + } else { + const updatedLabels = selectedLabels + ? [...selectedLabels] + : []; + + // can not deselect the last item + if (updatedLabels.length > 1) { + updatedLabels.splice( + updatedLabels.indexOf(item as LogSeverity), + 1, + ); + updateLabelFilter(updatedLabels); + } + } + }} + /> +
+ ))} +
+
+ + + ); +} diff --git a/web/src/components/filter/ReviewActionGroup.tsx b/web/src/components/filter/ReviewActionGroup.tsx index 3ab8684f4..cd31112af 100644 --- a/web/src/components/filter/ReviewActionGroup.tsx +++ b/web/src/components/filter/ReviewActionGroup.tsx @@ -40,7 +40,7 @@ export default function ReviewActionGroup({
{`${selectedReviews.length} selected`}
{"|"}
Unselect @@ -50,7 +50,6 @@ export default function ReviewActionGroup({ {selectedReviews.length == 1 && ( )}
diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index a3c3fadbc..2038aef42 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -75,6 +75,9 @@ export default function ReviewFilterGroup({ const cameras = filter?.cameras || Object.keys(config.cameras); cameras.forEach((camera) => { + if (camera == "birdseye") { + return; + } const cameraConfig = config.cameras[camera]; cameraConfig.objects.track.forEach((label) => { labels.add(label); @@ -220,23 +223,31 @@ function CamerasFilterButton({ const trigger = ( ); const content = ( <> - - Filter Cameras - - + {isMobile && ( + <> + + Cameras + + + + )}
@@ -411,9 +421,17 @@ function CalendarFilterButton({ ); const trigger = ( - @@ -428,7 +446,6 @@ function CalendarFilterButton({
); const content = ( @@ -546,7 +573,7 @@ export function GeneralFilterContent({