diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 3354a21cc..de823bf1c 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -51,7 +51,7 @@ ARG DEBIAN_FRONTEND # Install OpenVino Runtime and Dev library COPY docker/main/requirements-ov.txt /requirements-ov.txt RUN apt-get -qq update \ - && apt-get -qq install -y wget python3 python3-distutils \ + && apt-get -qq install -y wget python3 python3-dev python3-distutils gcc pkg-config libhdf5-dev \ && wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ && python3 get-pip.py "pip" \ && pip install -r /requirements-ov.txt diff --git a/docs/docs/configuration/cameras.md b/docs/docs/configuration/cameras.md index 47cc61cde..394cf935a 100644 --- a/docs/docs/configuration/cameras.md +++ b/docs/docs/configuration/cameras.md @@ -94,3 +94,23 @@ This list of working and non-working PTZ cameras is based on user feedback. | Tapo C210 | ❌ | ❌ | Incomplete ONVIF support | | Uniview IPC672LR-AX4DUPK | ✅ | ❌ | Firmware says FOV relative movement is supported, but camera doesn't actually move when sending ONVIF commands | | Vikylin PTZ-2804X-I2 | ❌ | ❌ | Incomplete ONVIF support | + +## Setting up camera groups + +:::tip + +It is recommended to set up camera groups using the UI. + +::: + +Cameras can be grouped together and assigned a name and icon, this allows them to be reviewed and filtered together. There will always be the default group for all cameras. + +```yaml +camera_groups: + front: + cameras: + - driveway_cam + - garage_cam + icon: car + order: 0 +``` \ No newline at end of file diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 5f89eddb7..829bae690 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -653,4 +653,19 @@ telemetry: # Optional: Enable the latest version outbound check (default: shown below) # NOTE: If you use the HomeAssistant integration, disabling this will prevent it from reporting new versions version_check: True + +# Optional: Camera groups (default: no groups are setup) +# NOTE: It is recommended to use the UI to setup camera groups +camera_groups: + # Required: Name of camera group + front: + # Required: list of cameras in the group + cameras: + - front_cam + - side_cam + - front_doorbell_cam + # Required: icon used for group + icon: car + # Required: index of this group + order: 0 ``` diff --git a/docs/docs/configuration/review.md b/docs/docs/configuration/review.md new file mode 100644 index 000000000..667401d0c --- /dev/null +++ b/docs/docs/configuration/review.md @@ -0,0 +1,47 @@ +--- +id: review +title: Review +--- + +Review items are saved as periods of time where frigate detected events. After watching the preview of a review item it is marked as reviewed. + +## Restricting alerts to specific labels + +By default a review item will only be marked as an alert if a person or car is detected. This can be configured to include any object or audio label using the following config: + +```yaml +# can be overridden at the camera level +review: + alerts: + labels: + - car + - cat + - dog + - person + - speech +``` + +## Restricting detections to specific labels + +By default all detections that do not qualify as an alert qualify as a detection. However, detections can further be filtered to only include certain labels or certain zones. + +By default a review item will only be marked as an alert if a person or car is detected. This can be configured to include any object or audio label using the following config: + +```yaml +# can be overridden at the camera level +review: + detections: + labels: + - bark + - dog +``` + +## Restricting review items to specific zones + +By default a review item will be created if any `review -> alerts -> labels` and `review -> detections -> labels` are detected anywhere in the camera frame. You will likely want to configure review items to only be created when the object enters an area of interest, [see the zone docs for more information](./zones.md#restricting-alerts-and-detections-to-specific-zones) + +:::info + +Because zones don't apply to audio, audio labels will always be marked as an alert. + +::: diff --git a/docs/docs/configuration/snapshots.md b/docs/docs/configuration/snapshots.md index 6145812db..dce689a67 100644 --- a/docs/docs/configuration/snapshots.md +++ b/docs/docs/configuration/snapshots.md @@ -3,6 +3,8 @@ id: snapshots title: Snapshots --- -Frigate can save a snapshot image to `/media/frigate/clips` for each event named as `-.jpg`. +Frigate can save a snapshot image to `/media/frigate/clips` for each object that is detected named as `-.jpg`. They are also accessible [via the api](../integrations/api.md#get-apieventsidsnapshotjpg) + +To only save snapshots for objects that enter a specific zone, [see the zone docs](./zones.md#restricting-snapshots-to-specific-zones) Snapshots sent via MQTT are configured in the [config file](https://docs.frigate.video/configuration/) under `cameras -> your_camera -> mqtt` diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index 177c47075..23fcea986 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -14,17 +14,47 @@ During testing, enable the Zones option for the debug feed so you can adjust as To create a zone, follow [the steps for a "Motion mask"](masks.md), but use the section of the web UI for creating a zone instead. -### Restricting events to specific zones +### Restricting alerts and detections to specific zones -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: +Often you will only want alerts 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 have an alert created when an object enters your entire_yard zone, the config would be: ```yaml cameras: name_of_your_camera: - record: - events: + review: + alerts: required_zones: - entire_yard + zones: + entire_yard: + coordinates: ... +``` + +You may also want to filter detections to only be created when an object enters a secondary area of interest. This is done using zones along with setting required_zones. Let's say you want alerts when an object enters the inner area of the yard but detections when an object enters the edge of the yard, the config would be + +```yaml +cameras: + name_of_your_camera: + review: + alerts: + required_zones: + - inner_yard + detections: + required_zones: + - edge_yard + zones: + edge_yard: + coordinates: ... + inner_yard: + coordinates: ... + +``` + +### Restricting snapshots to specific zones + +```yaml +cameras: + name_of_your_camera: snapshots: required_zones: - entire_yard diff --git a/docs/sidebars.js b/docs/sidebars.js index d4a3d9daf..eff51212c 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -32,6 +32,7 @@ module.exports = { ], Cameras: [ "configuration/cameras", + "configuration/review", "configuration/record", "configuration/snapshots", "configuration/motion_detection", diff --git a/frigate/api/app.py b/frigate/api/app.py index 5d0bce78b..67ad072a7 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -164,6 +164,7 @@ def config(): camera_dict["zones"][zone_name]["color"] = zone.color config["plus"] = {"enabled": current_app.plus_api.is_active()} + config["model"]["colormap"] = config_obj.model.colormap for detector_config in config["detectors"].values(): detector_config["model"]["labelmap"] = ( diff --git a/frigate/api/media.py b/frigate/api/media.py index 519467643..d493b6fa9 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -26,6 +26,7 @@ from frigate.const import ( ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment from frigate.util.builtin import get_tz_modifiers +from frigate.util.image import get_image_from_recording logger = logging.getLogger(__name__) @@ -205,30 +206,20 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str): try: recording: Recordings = recording_query.get() time_in_segment = frame_time - recording.start_time + image_data = get_image_from_recording(recording.path, time_in_segment) - ffmpeg_cmd = [ - "ffmpeg", - "-hide_banner", - "-loglevel", - "warning", - "-ss", - f"00:00:{time_in_segment}", - "-i", - recording.path, - "-frames:v", - "1", - "-c:v", - "png", - "-f", - "image2pipe", - "-", - ] + if not image_data: + return make_response( + jsonify( + { + "success": False, + "message": f"Unable to parse frame at time {frame_time}", + } + ), + 404, + ) - process = sp.run( - ffmpeg_cmd, - capture_output=True, - ) - response = make_response(process.stdout) + response = make_response(image_data) response.headers["Content-Type"] = "image/png" return response except DoesNotExist: @@ -243,6 +234,71 @@ def get_snapshot_from_recording(camera_name: str, frame_time: str): ) +@MediaBp.route("//plus/", methods=("POST",)) +def submit_recording_snapshot_to_plus(camera_name: str, frame_time: str): + if camera_name not in current_app.frigate_config.cameras: + return make_response( + jsonify({"success": False, "message": "Camera not found"}), + 404, + ) + + frame_time = float(frame_time) + recording_query = ( + Recordings.select( + Recordings.path, + Recordings.start_time, + ) + .where( + ( + (frame_time >= Recordings.start_time) + & (frame_time <= Recordings.end_time) + ) + ) + .where(Recordings.camera == camera_name) + .order_by(Recordings.start_time.desc()) + .limit(1) + ) + + try: + recording: Recordings = recording_query.get() + time_in_segment = frame_time - recording.start_time + image_data = get_image_from_recording(recording.path, time_in_segment) + + if not image_data: + return make_response( + jsonify( + { + "success": False, + "message": f"Unable to parse frame at time {frame_time}", + } + ), + 404, + ) + + nd = cv2.imdecode(np.frombuffer(image_data, dtype=np.int8), cv2.IMREAD_COLOR) + current_app.plus_api.upload_image(nd, camera_name) + + return make_response( + jsonify( + { + "success": True, + "message": "Successfully submitted image.", + } + ), + 200, + ) + except DoesNotExist: + return make_response( + jsonify( + { + "success": False, + "message": "Recording not found at {}".format(frame_time), + } + ), + 404, + ) + + @MediaBp.route("/recordings/storage", methods=["GET"]) def get_recordings_storage_usage(): recording_stats = current_app.stats_emitter.get_latest_stats()["service"][ @@ -392,7 +448,17 @@ def recording_clip(camera_name, start_ts, end_ts): if clip.end_time > end_ts: playlist_lines.append(f"outpoint {int(end_ts - clip.start_time)}") - file_name = secure_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4") + file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4" + + if len(file_name) > 1000: + return make_response( + jsonify( + {"success": False, "message": "Filename exceeded max length of 1000"} + ), + 403, + ) + + file_name = secure_filename(file_name) path = os.path.join(CACHE_DIR, file_name) if not os.path.exists(path): @@ -1167,7 +1233,20 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000): @MediaBp.route("//start//end//preview.mp4") @MediaBp.route("//start//end//preview.mp4") def preview_mp4(camera_name: str, start_ts, end_ts): - file_name = secure_filename(f"clip_{camera_name}_{start_ts}-{end_ts}.mp4") + file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4" + + if len(file_name) > 1000: + return make_response( + jsonify( + { + "success": False, + "message": "Filename exceeded max length of 1000 characters.", + } + ), + 403, + ) + + file_name = secure_filename(file_name) path = os.path.join(CACHE_DIR, file_name) if datetime.fromtimestamp(start_ts) < datetime.now().replace(minute=0, second=0): @@ -1337,6 +1416,14 @@ def review_preview(id: str): @MediaBp.route("/preview//thumbnail.webp") def preview_thumbnail(file_name: str): """Get a thumbnail from the cached preview frames.""" + if len(file_name) > 1000: + return make_response( + jsonify( + {"success": False, "message": "Filename exceeded max length of 1000"} + ), + 403, + ) + safe_file_name_current = secure_filename(file_name) preview_dir = os.path.join(CACHE_DIR, "preview_frames") diff --git a/frigate/api/review.py b/frigate/api/review.py index fa1dee73c..855122989 100644 --- a/frigate/api/review.py +++ b/frigate/api/review.py @@ -440,6 +440,7 @@ def motion_activity(): # resample data using pandas to get activity on scaled basis df = pd.DataFrame(data, columns=["start_time", "motion", "camera"]) + df = df.astype(dtype={"motion": "float16"}) # set date as datetime index df["start_time"] = pd.to_datetime(df["start_time"], unit="s") @@ -517,6 +518,7 @@ def audio_activity(): # resample data using pandas to get activity on scaled basis df = pd.DataFrame(data, columns=["start_time", "audio"]) + df = df.astype(dtype={"audio": "float16"}) # set date as datetime index df["start_time"] = pd.to_datetime(df["start_time"], unit="s") diff --git a/frigate/app.py b/frigate/app.py index 94ec8c6c6..1fce7c1ac 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -16,6 +16,7 @@ import psutil from peewee_migrate import Router from playhouse.sqlite_ext import SqliteExtDatabase from playhouse.sqliteq import SqliteQueueDatabase +from pydantic import ValidationError from frigate.api.app import create_app from frigate.comms.config_updater import ConfigPublisher @@ -597,24 +598,6 @@ class FrigateApp: self.init_logger() logger.info(f"Starting Frigate ({VERSION})") - if not os.environ.get("I_PROMISE_I_WONT_MAKE_AN_ISSUE_ON_GITHUB"): - print( - "**********************************************************************************" - ) - print( - "**********************************************************************************" - ) - print("Frigate 0.14 UNSTABLE") - print("This build is not for public use. Please use Frigate stable.") - print("Unstable/experimental builds are not enabled, Frigate is exiting.") - print( - "**********************************************************************************" - ) - print( - "**********************************************************************************" - ) - sys.exit(1) - try: self.ensure_dirs() try: @@ -629,8 +612,13 @@ class FrigateApp: print("*************************************************************") print("*** Config Validation Errors ***") print("*************************************************************") - print(e) - print(traceback.format_exc()) + if isinstance(e, ValidationError): + for error in e.errors(): + location = ".".join(str(item) for item in error["loc"]) + print(f"{location}: {error['msg']}") + else: + print(e) + print(traceback.format_exc()) print("*************************************************************") print("*** End Config Validation Errors ***") print("*************************************************************") @@ -695,9 +683,9 @@ class FrigateApp: 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() + Event.update( + end_time=datetime.datetime.now().timestamp(), has_snapshot=False + ).where(Event.end_time == None).execute() ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where( ReviewSegment.end_time == None ).execute() diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 6fc3885e0..db6c44c11 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -1,6 +1,7 @@ """Handle communication between Frigate and other applications.""" import datetime +import json import logging from abc import ABC, abstractmethod from typing import Any, Callable, Optional @@ -12,6 +13,7 @@ from frigate.const import ( INSERT_MANY_RECORDINGS, INSERT_PREVIEW, REQUEST_REGION_GRID, + UPDATE_CAMERA_ACTIVITY, UPSERT_REVIEW_SEGMENT, ) from frigate.models import Previews, Recordings, ReviewSegment @@ -76,6 +78,8 @@ class Dispatcher: for comm in self.comms: comm.subscribe(self._receive) + self.camera_activity = {} + def _receive(self, topic: str, payload: str) -> Optional[Any]: """Handle receiving of payload from communicators.""" if topic.endswith("set"): @@ -122,6 +126,10 @@ class Dispatcher: ReviewSegment.update(end_time=datetime.datetime.now().timestamp()).where( ReviewSegment.end_time == None ).execute() + elif topic == UPDATE_CAMERA_ACTIVITY: + self.camera_activity = payload + elif topic == "onConnect": + self.publish("camera_activity", json.dumps(self.camera_activity)) else: self.publish(topic, payload, retain=False) diff --git a/frigate/config.py b/frigate/config.py index d7ee147f3..008176956 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -518,7 +518,7 @@ class ZoneConfig(BaseModel): ge=0, title="Number of seconds that an object must loiter to be considered in the zone.", ) - objects: List[str] = Field( + objects: Union[str, List[str]] = Field( default_factory=list, title="List of objects that can trigger the zone.", ) @@ -555,19 +555,24 @@ class ZoneConfig(BaseModel): # 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])] - if explicit - else [ - int(float(p.split(",")[0]) * frame_shape[1]), - int(float(p.split(",")[1]) * frame_shape[0]), - ] - ) - for p in coordinates - ] - ) + try: + self._contour = np.array( + [ + ( + [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 + ] + ) + except ValueError: + raise ValueError( + f"Invalid coordinates found in configuration file. Coordinates must be relative (between 0-1): {coordinates}" + ) if explicit: self.coordinates = ",".join( @@ -579,19 +584,24 @@ class ZoneConfig(BaseModel): 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])] - 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) - ] - ) + try: + self._contour = np.array( + [ + ( + [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) + ] + ) + except ValueError: + raise ValueError( + f"Invalid coordinates found in configuration file. Coordinates must be relative (between 0-1): {coordinates}" + ) if explicit: self.coordinates = ",".join( @@ -616,7 +626,7 @@ class AlertsConfig(FrigateBaseModel): labels: List[str] = Field( default=DEFAULT_ALERT_OBJECTS, title="Labels to create alerts for." ) - required_zones: List[str] = Field( + required_zones: Union[str, List[str]] = Field( default_factory=list, title="List of required zones to be entered in order to save the event as an alert.", ) @@ -636,7 +646,7 @@ class DetectionsConfig(FrigateBaseModel): labels: Optional[List[str]] = Field( default=None, title="Labels to create detections for." ) - required_zones: List[str] = Field( + required_zones: Union[str, List[str]] = Field( default_factory=list, title="List of required zones to be entered in order to save the event as a detection.", ) @@ -1505,29 +1515,26 @@ class FrigateConfig(FrigateBaseModel): for key, detector in config.detectors.items(): adapter = TypeAdapter(DetectorConfig) model_dict = ( - detector if isinstance(detector, dict) else detector.model_dump() + detector + if isinstance(detector, dict) + else detector.model_dump(warnings="none") ) detector_config: DetectorConfig = adapter.validate_python(model_dict) if detector_config.model is None: - detector_config.model = config.model + detector_config.model = config.model.model_copy() else: - model = detector_config.model - schema = ModelConfig.model_json_schema()["properties"] - if ( - model.width != schema["width"]["default"] - or model.height != schema["height"]["default"] - or model.labelmap_path is not None - or model.labelmap - or model.input_tensor != schema["input_tensor"]["default"] - or model.input_pixel_format - != schema["input_pixel_format"]["default"] - ): + path = detector_config.model.path + detector_config.model = config.model.model_copy() + detector_config.model.path = path + + if "path" not in model_dict or len(model_dict.keys()) > 1: logger.warning( "Customizing more than a detector model path is unsupported." ) + merged_model = deep_merge( - detector_config.model.model_dump(exclude_unset=True), - config.model.model_dump(exclude_unset=True), + detector_config.model.model_dump(exclude_unset=True, warnings="none"), + config.model.model_dump(exclude_unset=True, warnings="none"), ) if "path" not in merged_model: diff --git a/frigate/const.py b/frigate/const.py index 168d880fb..030d507ed 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -80,6 +80,7 @@ INSERT_PREVIEW = "insert_preview" REQUEST_REGION_GRID = "request_region_grid" UPSERT_REVIEW_SEGMENT = "upsert_review_segment" CLEAR_ONGOING_REVIEW_SEGMENTS = "clear_ongoing_review_segments" +UPDATE_CAMERA_ACTIVITY = "update_camera_activity" # Autotracking diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index 29ea54ed7..88b4f0de6 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -184,43 +184,6 @@ class EventCleanup(threading.Thread): Event.update(update_params).where(Event.id << events_to_update).execute() return events_to_update - def purge_duplicates(self) -> None: - duplicate_query = """with grouped_events as ( - select id, - label, - camera, - has_snapshot, - has_clip, - end_time, - row_number() over ( - partition by label, camera, round(start_time/5,0)*5 - order by end_time-start_time desc - ) as copy_number - from event - ) - - select distinct id, camera, has_snapshot, has_clip from grouped_events - where copy_number > 1 and end_time not null;""" - - duplicate_events: list[Event] = Event.raw(duplicate_query) - for event in duplicate_events: - logger.debug(f"Removing duplicate: {event.id}") - - try: - media_name = f"{event.camera}-{event.id}" - media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") - media_path.unlink(missing_ok=True) - media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png") - media_path.unlink(missing_ok=True) - except OSError as e: - logger.warning(f"Unable to delete event images: {e}") - - ( - Event.delete() - .where(Event.id << [event.id for event in duplicate_events]) - .execute() - ) - def run(self) -> None: # only expire events every 5 minutes while not self.stop_event.wait(300): @@ -232,7 +195,6 @@ class EventCleanup(threading.Thread): ).execute() self.expire(EventCleanupType.snapshots) - self.purge_duplicates() # drop events from db where has_clip and has_snapshot are false delete_query = Event.delete().where( diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 9da0e2b25..676c175ec 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -16,6 +16,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 EventEndSubscriber, EventUpdatePublisher +from frigate.comms.inter_process import InterProcessRequestor from frigate.config import ( CameraConfig, FrigateConfig, @@ -24,7 +25,7 @@ from frigate.config import ( SnapshotsConfig, ZoomingModeEnum, ) -from frigate.const import CLIPS_DIR +from frigate.const import ALL_ATTRIBUTE_LABELS, CLIPS_DIR, UPDATE_CAMERA_ACTIVITY from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.ptz.autotrack import PtzAutoTrackerThread from frigate.util.image import ( @@ -724,8 +725,41 @@ class CameraState: # TODO: can i switch to looking this up and only changing when an event ends? # maintain best objects + camera_activity: dict[str, list[any]] = { + "motion": len(motion_boxes) > 0, + "objects": [], + } + for obj in tracked_objects.values(): object_type = obj.obj_data["label"] + active = ( + obj.obj_data["motionless_count"] + < self.camera_config.detect.stationary.threshold + ) + + if not obj.false_positive: + label = object_type + sub_label = None + + if obj.obj_data.get("sub_label"): + if obj.obj_data.get("sub_label")[0] in ALL_ATTRIBUTE_LABELS: + label = obj.obj_data["sub_label"][0] + else: + label = f"{object_type}-verified" + sub_label = obj.obj_data["sub_label"][0] + + camera_activity["objects"].append( + { + "id": obj.obj_data["id"], + "label": label, + "stationary": not active, + "area": obj.obj_data["area"], + "ratio": obj.obj_data["ratio"], + "score": obj.obj_data["score"], + "sub_label": sub_label, + } + ) + # if the object's thumbnail is not from the current frame if obj.false_positive or obj.thumbnail_data["frame_time"] != frame_time: continue @@ -752,6 +786,9 @@ class CameraState: for c in self.callbacks["snapshot"]: c(self.name, self.best_objects[object_type], frame_time) + for c in self.callbacks["camera_activity"]: + c(self.name, camera_activity) + # update overall camera state for each object type obj_counter = Counter( obj.obj_data["label"] @@ -841,10 +878,14 @@ class TrackedObjectProcessor(threading.Thread): self.frame_manager = SharedMemoryFrameManager() self.last_motion_detected: dict[str, float] = {} self.ptz_autotracker_thread = ptz_autotracker_thread + + self.requestor = InterProcessRequestor() self.detection_publisher = DetectionPublisher(DetectionTypeEnum.video) self.event_sender = EventUpdatePublisher() self.event_end_subscriber = EventEndSubscriber() + self.camera_activity: dict[str, dict[str, any]] = {} + def start(camera, obj: TrackedObject, current_frame_time): self.event_sender.publish( ( @@ -962,6 +1003,13 @@ class TrackedObjectProcessor(threading.Thread): def object_status(camera, object_name, status): self.dispatcher.publish(f"{camera}/{object_name}", status, retain=False) + def camera_activity(camera, activity): + last_activity = self.camera_activity.get(camera) + + if not last_activity or activity != last_activity: + self.camera_activity[camera] = activity + self.requestor.send_data(UPDATE_CAMERA_ACTIVITY, self.camera_activity) + for camera in self.config.cameras.keys(): camera_state = CameraState( camera, self.config, self.frame_manager, self.ptz_autotracker_thread @@ -972,6 +1020,7 @@ class TrackedObjectProcessor(threading.Thread): camera_state.on("end", end) camera_state.on("snapshot", snapshot) camera_state.on("object_status", object_status) + camera_state.on("camera_activity", camera_activity) self.camera_states[camera] = camera_state # { @@ -1228,6 +1277,7 @@ class TrackedObjectProcessor(threading.Thread): event_id, camera = update self.camera_states[camera].finished(event_id) + self.requestor.stop() self.detection_publisher.stop() self.event_sender.stop() self.event_end_subscriber.stop() diff --git a/frigate/record/export.py b/frigate/record/export.py index 89980c663..efc155838 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -110,6 +110,8 @@ class RecordingExporter(threading.Thread): f"00:{minutes}:{seconds}", "-i", preview.path, + "-frames", + "1", "-c:v", "libwebp", thumb_path, diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 295641656..77d3d2a6b 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -110,6 +110,18 @@ class PendingReviewSegment: self.frame_path, self.frame, [int(cv2.IMWRITE_WEBP_QUALITY), 60] ) + def save_full_frame(self, camera_config: CameraConfig, frame): + color_frame = cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) + width = int(THUMB_HEIGHT * color_frame.shape[1] / color_frame.shape[0]) + self.frame = cv2.resize( + color_frame, dsize=(width, THUMB_HEIGHT), interpolation=cv2.INTER_AREA + ) + + if self.frame is not None: + 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, @@ -273,8 +285,30 @@ class ReviewSegmentMaintainer(threading.Thread): if segment.severity == SeverityEnum.alert and frame_time > ( segment.last_update + THRESHOLD_ALERT_ACTIVITY ): + if segment.frame is None: + try: + frame_id = f"{camera_config.name}{frame_time}" + yuv_frame = self.frame_manager.get( + frame_id, camera_config.frame_shape_yuv + ) + segment.save_full_frame(camera_config, yuv_frame) + self.frame_manager.close(frame_id) + except FileNotFoundError: + return + self.end_segment(segment) elif frame_time > (segment.last_update + THRESHOLD_DETECTION_ACTIVITY): + if segment.frame is None: + try: + frame_id = f"{camera_config.name}{frame_time}" + yuv_frame = self.frame_manager.get( + frame_id, camera_config.frame_shape_yuv + ) + segment.save_full_frame(camera_config, yuv_frame) + self.frame_manager.close(frame_id) + except FileNotFoundError: + return + self.end_segment(segment) def check_if_new_segment( @@ -511,7 +545,7 @@ class ReviewSegmentMaintainer(threading.Thread): manual_info["label"] ) # temporarily make it so this event can not end - self.active_review_segments[camera] = sys.maxsize + self.active_review_segments[camera].last_update = sys.maxsize elif manual_info["state"] == ManualEventState.complete: self.active_review_segments[camera].last_update = manual_info[ "end_time" diff --git a/frigate/stats/util.py b/frigate/stats/util.py index 8eb59e464..09710b358 100644 --- a/frigate/stats/util.py +++ b/frigate/stats/util.py @@ -162,7 +162,7 @@ async def set_gpu_stats( for args in hwaccel_args: if args in hwaccel_errors: # known erroring args should automatically return as error - stats["error-gpu"] = {"gpu": -1, "mem": -1} + stats["error-gpu"] = {"gpu": "", "mem": ""} elif "cuvid" in args or "nvidia" in args: # nvidia GPU nvidia_usage = get_nvidia_gpu_stats() @@ -177,7 +177,7 @@ async def set_gpu_stats( } else: - stats["nvidia-gpu"] = {"gpu": -1, "mem": -1} + stats["nvidia-gpu"] = {"gpu": "", "mem": ""} hwaccel_errors.append(args) elif "nvmpi" in args or "jetson" in args: # nvidia Jetson @@ -186,7 +186,7 @@ async def set_gpu_stats( if jetson_usage: stats["jetson-gpu"] = jetson_usage else: - stats["jetson-gpu"] = {"gpu": -1, "mem": -1} + stats["jetson-gpu"] = {"gpu": "", "mem": ""} hwaccel_errors.append(args) elif "qsv" in args: if not config.telemetry.stats.intel_gpu_stats: @@ -198,7 +198,7 @@ async def set_gpu_stats( if intel_usage: stats["intel-qsv"] = intel_usage else: - stats["intel-qsv"] = {"gpu": -1, "mem": -1} + stats["intel-qsv"] = {"gpu": "", "mem": ""} hwaccel_errors.append(args) elif "vaapi" in args: if is_vaapi_amd_driver(): @@ -211,7 +211,7 @@ async def set_gpu_stats( if amd_usage: stats["amd-vaapi"] = amd_usage else: - stats["amd-vaapi"] = {"gpu": -1, "mem": -1} + stats["amd-vaapi"] = {"gpu": "", "mem": ""} hwaccel_errors.append(args) else: if not config.telemetry.stats.intel_gpu_stats: @@ -223,11 +223,11 @@ async def set_gpu_stats( if intel_usage: stats["intel-vaapi"] = intel_usage else: - stats["intel-vaapi"] = {"gpu": -1, "mem": -1} + stats["intel-vaapi"] = {"gpu": "", "mem": ""} hwaccel_errors.append(args) elif "v4l2m2m" in args or "rpi" in args: # RPi v4l2m2m is currently not able to get usage stats - stats["rpi-v4l2m2m"] = {"gpu": -1, "mem": -1} + stats["rpi-v4l2m2m"] = {"gpu": "", "mem": ""} if stats: all_stats["gpu_usages"] = stats diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index be935d431..697edfe91 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -82,7 +82,7 @@ class TestConfig(unittest.TestCase): }, "edgetpu": { "type": "edgetpu", - "model": {"path": "/edgetpu_model.tflite", "width": 160}, + "model": {"path": "/edgetpu_model.tflite"}, }, "openvino": { "type": "openvino", @@ -112,11 +112,6 @@ class TestConfig(unittest.TestCase): assert runtime_config.detectors["edgetpu"].model.path == "/edgetpu_model.tflite" assert runtime_config.detectors["openvino"].model.path == "/etc/hosts" - assert runtime_config.model.width == 512 - assert runtime_config.detectors["cpu"].model.width == 320 - assert runtime_config.detectors["edgetpu"].model.width == 160 - assert runtime_config.detectors["openvino"].model.width == 512 - def test_invalid_mqtt_config(self): config = { "mqtt": {"host": "mqtt", "user": "test"}, diff --git a/frigate/track/norfair_tracker.py b/frigate/track/norfair_tracker.py index 53cdaad6f..f3e5a0ac0 100644 --- a/frigate/track/norfair_tracker.py +++ b/frigate/track/norfair_tracker.py @@ -22,8 +22,9 @@ from frigate.util.object import average_boxes, median_of_boxes logger = logging.getLogger(__name__) -THRESHOLD_ACTIVE_IOU = 0.2 -THRESHOLD_STATIONARY_IOU = 0.6 +THRESHOLD_KNOWN_ACTIVE_IOU = 0.2 +THRESHOLD_STATIONARY_CHECK_IOU = 0.6 +THRESHOLD_ACTIVE_CHECK_IOU = 0.9 MAX_STATIONARY_HISTORY = 10 @@ -146,7 +147,7 @@ class NorfairTracker(ObjectTracker): # tracks the current position of the object based on the last N bounding boxes # returns False if the object has moved outside its previous position - def update_position(self, id: str, box: list[int, int, int, int]): + def update_position(self, id: str, box: list[int, int, int, int], stationary: bool): xmin, ymin, xmax, ymax = box position = self.positions[id] self.stationary_box_history[id].append(box) @@ -162,7 +163,7 @@ class NorfairTracker(ObjectTracker): # object has minimal or zero iou # assume object is active - if avg_iou < THRESHOLD_ACTIVE_IOU: + if avg_iou < THRESHOLD_KNOWN_ACTIVE_IOU: self.positions[id] = { "xmins": [xmin], "ymins": [ymin], @@ -175,8 +176,12 @@ class NorfairTracker(ObjectTracker): } return False + threshold = ( + THRESHOLD_STATIONARY_CHECK_IOU if stationary else THRESHOLD_ACTIVE_CHECK_IOU + ) + # object has iou below threshold, check median to reduce outliers - if avg_iou < THRESHOLD_STATIONARY_IOU: + if avg_iou < threshold: median_iou = intersection_over_union( ( position["xmin"], @@ -189,7 +194,7 @@ class NorfairTracker(ObjectTracker): # if the median iou drops below the threshold # assume object is no longer stationary - if median_iou < THRESHOLD_STATIONARY_IOU: + if median_iou < threshold: self.positions[id] = { "xmins": [xmin], "ymins": [ymin], @@ -240,8 +245,12 @@ class NorfairTracker(ObjectTracker): def update(self, track_id, obj): id = self.track_id_map[track_id] self.disappeared[id] = 0 + stationary = ( + self.tracked_objects[id]["motionless_count"] + >= self.detect_config.stationary.threshold + ) # update the motionless count if the object has not moved to a new position - if self.update_position(id, obj["box"]): + if self.update_position(id, obj["box"], stationary): self.tracked_objects[id]["motionless_count"] += 1 if self.is_expired(id): self.deregister(id, track_id) diff --git a/frigate/util/config.py b/frigate/util/config.py index d720df067..e882f0bfd 100644 --- a/frigate/util/config.py +++ b/frigate/util/config.py @@ -155,14 +155,18 @@ def get_relative_coordinates( 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) - ] + + if any(x > "1.0" for x in points): + 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) + ] + ) ) - ) + else: + relative_masks.append(m) mask = relative_masks elif isinstance(mask, str) and any(x > "1.0" for x in mask.split(",")): diff --git a/frigate/util/image.py b/frigate/util/image.py index 67f8b5c22..3962d9600 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -2,6 +2,7 @@ import datetime import logging +import subprocess as sp from abc import ABC, abstractmethod from multiprocessing import shared_memory from string import printable @@ -746,3 +747,37 @@ def add_mask(mask: str, mask_img: np.ndarray): ] ) cv2.fillPoly(mask_img, pts=[contour], color=(0)) + + +def get_image_from_recording( + file_path: str, relative_frame_time: float +) -> Optional[any]: + """retrieve a frame from given time in recording file.""" + + ffmpeg_cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "warning", + "-ss", + f"00:00:{relative_frame_time}", + "-i", + file_path, + "-frames:v", + "1", + "-c:v", + "png", + "-f", + "image2pipe", + "-", + ] + + process = sp.run( + ffmpeg_cmd, + capture_output=True, + ) + + if process.returncode == 0: + return process.stdout + else: + return None diff --git a/web/package-lock.json b/web/package-lock.json index 122c26570..1a680777a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -29,31 +29,32 @@ "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "apexcharts": "^3.48.0", + "apexcharts": "^3.49.0", "axios": "^1.6.8", "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", + "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", "hls.js": "^1.5.8", "idb-keyval": "^6.2.1", - "immer": "^10.0.4", + "immer": "^10.1.1", "konva": "^9.3.6", "lodash": "^4.17.21", - "lucide-react": "^0.372.0", + "lucide-react": "^0.378.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.3.0", - "react": "^18.2.0", + "react": "^18.3.1", "react-apexcharts": "^1.4.1", "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", - "react-dom": "^18.2.0", - "react-hook-form": "^7.51.3", - "react-icons": "^5.1.0", + "react-dom": "^18.3.1", + "react-grid-layout": "^1.4.4", + "react-hook-form": "^7.51.4", + "react-icons": "^5.2.1", "react-konva": "^18.2.10", - "react-router-dom": "^6.22.3", + "react-router-dom": "^6.23.0", "react-swipeable": "^7.0.1", - "react-tracked": "^1.7.14", + "react-tracked": "^2.0.0", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", "react-zoom-pan-pinch": "^3.4.4", @@ -65,24 +66,25 @@ "swr": "^2.2.5", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.0", + "vaul": "^0.9.1", "vite-plugin-monaco-editor": "^1.1.0", - "zod": "^3.22.5" + "zod": "^3.23.7" }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", "@testing-library/jest-dom": "^6.1.5", - "@types/lodash": "^4.17.0", - "@types/node": "^20.12.7", - "@types/react": "^18.2.79", - "@types/react-dom": "^18.2.25", + "@types/lodash": "^4.17.1", + "@types/node": "^20.12.11", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "@types/react-grid-layout": "^1.3.5", "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", "@types/strftime": "^0.9.8", "@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", + "@vitest/coverage-v8": "^1.6.0", "autoprefixer": "^10.4.19", "eslint": "^8.55.0", "eslint-config-prettier": "^9.1.0", @@ -99,8 +101,8 @@ "prettier": "^3.2.5", "tailwindcss": "^3.4.3", "typescript": "^5.4.5", - "vite": "^5.2.9", - "vitest": "^1.4.0" + "vite": "^5.2.11", + "vitest": "^1.6.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2029,9 +2031,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", - "integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.0.tgz", + "integrity": "sha512-Quz1KOffeEf/zwkCBM3kBtH4ZoZ+pT3xIXBG4PPW/XFtDP7EGhtTiC2+gpL9GnR7+Qdet5Oa6cYSvwKYg6kN9Q==", "engines": { "node": ">=14.0.0" } @@ -2514,21 +2516,15 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "node_modules/@types/lodash": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", - "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.1.tgz", + "integrity": "sha512-X+2qazGS3jxLAIz5JDXDzglAF3KpijdhFxlf/V1+hEsOUc+HnWi81L/uv/EvGuV90WY+7mPGFCUDGfQC3Gj95Q==", "dev": true }, "node_modules/@types/mute-stream": { @@ -2541,9 +2537,9 @@ } }, "node_modules/@types/node": { - "version": "20.12.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", - "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "version": "20.12.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz", + "integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -2555,23 +2551,32 @@ "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/react": { - "version": "18.2.79", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz", - "integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", + "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "18.2.25", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz", - "integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==", + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", "devOptional": true, "dependencies": { "@types/react": "*" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz", + "integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-icons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz", @@ -2856,9 +2861,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.4.0.tgz", - "integrity": "sha512-4hDGyH1SvKpgZnIByr9LhGgCEuF9DKM34IBLCC/fVfy24Z3+PZ+Ii9hsVBsHvY1umM1aGPEjceRkzxCfcQ10wg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz", + "integrity": "sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.1", @@ -2873,24 +2878,23 @@ "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", - "test-exclude": "^6.0.0", - "v8-to-istanbul": "^9.2.0" + "test-exclude": "^6.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "1.4.0" + "vitest": "1.6.0" } }, "node_modules/@vitest/expect": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.4.0.tgz", - "integrity": "sha512-Jths0sWCJZ8BxjKe+p+eKsoqev1/T8lYcrjavEaz8auEJ4jAVY0GwW3JKmdVU4mmNPLPHixh4GNXP7GFtAiDHA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz", + "integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==", "dev": true, "dependencies": { - "@vitest/spy": "1.4.0", - "@vitest/utils": "1.4.0", + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", "chai": "^4.3.10" }, "funding": { @@ -2898,12 +2902,12 @@ } }, "node_modules/@vitest/runner": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.4.0.tgz", - "integrity": "sha512-EDYVSmesqlQ4RD2VvWo3hQgTJ7ZrFQ2VSJdfiJiArkCerDAGeyF1i6dHkmySqk573jLp6d/cfqCN+7wUB5tLgg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz", + "integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==", "dev": true, "dependencies": { - "@vitest/utils": "1.4.0", + "@vitest/utils": "1.6.0", "p-limit": "^5.0.0", "pathe": "^1.1.1" }, @@ -2939,9 +2943,9 @@ } }, "node_modules/@vitest/snapshot": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.4.0.tgz", - "integrity": "sha512-saAFnt5pPIA5qDGxOHxJ/XxhMFKkUSBJmVt5VgDsAqPTX6JP326r5C/c9UuCMPoXNzuudTPsYDZCoJ5ilpqG2A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz", + "integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==", "dev": true, "dependencies": { "magic-string": "^0.30.5", @@ -2953,9 +2957,9 @@ } }, "node_modules/@vitest/spy": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.4.0.tgz", - "integrity": "sha512-Ywau/Qs1DzM/8Uc+yA77CwSegizMlcgTJuYGAi0jujOteJOUf1ujunHThYo243KG9nAyWT3L9ifPYZ5+As/+6Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz", + "integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==", "dev": true, "dependencies": { "tinyspy": "^2.2.0" @@ -2965,9 +2969,9 @@ } }, "node_modules/@vitest/utils": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.4.0.tgz", - "integrity": "sha512-mx3Yd1/6e2Vt/PUC98DcqTirtfxUyAZ32uK82r8rZzbtBeBo+nqgnjx/LvqQdWsrvNtm14VmurNgcf4nqY5gJg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==", "dev": true, "dependencies": { "diff-sequences": "^29.6.3", @@ -3111,9 +3115,9 @@ } }, "node_modules/apexcharts": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.48.0.tgz", - "integrity": "sha512-Lhpj1Ij6lKlrUke8gf+P+SE6uGUn+Pe1TnCJ+zqrY0YMvbqM3LMb1lY+eybbTczUyk0RmMZomlTa2NgX2EUs4Q==", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.49.0.tgz", + "integrity": "sha512-2T9HnbQFLCuYRPndQLmh+bEQFoz0meUbvASaGgiSKDuYhWcLBodJtIpKql2aOtMx4B/sHrWW0dm90HsW4+h2PQ==", "dependencies": { "@yr/monotone-cubic-spline": "^1.0.3", "svg.draggable.js": "^2.2.2", @@ -3532,9 +3536,9 @@ } }, "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "engines": { "node": ">=6" } @@ -3586,12 +3590,6 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -4392,6 +4390,11 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -4815,9 +4818,9 @@ } }, "node_modules/immer": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.4.tgz", - "integrity": "sha512-cuBuGK40P/sk5IzWa9QPUaAdvPHjkk1c+xYsd9oZw+YQQEV+10G0P5uMpGctZZKnyQ+ibRO08bD25nWLmYi2pw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -5348,9 +5351,9 @@ } }, "node_modules/lucide-react": { - "version": "0.372.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.372.0.tgz", - "integrity": "sha512-0cKdqmilHXWUwWAWnf6CrrjHD8YaqPMtLrmEHXolZusNTr9epULCsiJwIOHk2q1yFxdEwd96D4zShlAj67UJdA==", + "version": "0.378.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.378.0.tgz", + "integrity": "sha512-u6EPU8juLUk9ytRcyapkWI18epAv3RU+6+TC23ivjR0e+glWKBobFeSgRwOIJihzktILQuy6E0E80P2jVTDR5g==", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } @@ -6220,9 +6223,9 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proxy-compare": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz", - "integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz", + "integrity": "sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w==" }, "node_modules/proxy-from-env": { "version": "1.1.0", @@ -6270,9 +6273,9 @@ ] }, "node_modules/react": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", - "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dependencies": { "loose-envify": "^1.1.0" }, @@ -6318,21 +6321,59 @@ } }, "node_modules/react-dom": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", - "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dependencies": { "loose-envify": "^1.1.0", - "scheduler": "^0.23.0" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^18.2.0" + "react": "^18.3.1" + } + }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-grid-layout": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz", + "integrity": "sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==", + "dependencies": { + "clsx": "^2.0.0", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.5", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" } }, "node_modules/react-hook-form": { - "version": "7.51.3", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz", - "integrity": "sha512-cvJ/wbHdhYx8aviSWh28w9ImjmVsb5Y05n1+FW786vEZQJV5STNM0pW6ujS+oiBecb0ARBxJFyAnXj9+GHXACQ==", + "version": "7.51.4", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.4.tgz", + "integrity": "sha512-V14i8SEkh+V1gs6YtD0hdHYnoL4tp/HX/A45wWQN15CYr9bFRmmRdYStSO5L65lCCZRF+kYiSKhm9alqbcdiVA==", "engines": { "node": ">=12.22.0" }, @@ -6345,9 +6386,9 @@ } }, "node_modules/react-icons": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.1.0.tgz", - "integrity": "sha512-D3zug1270S4hbSlIRJ0CUS97QE1yNNKDjzQe3HqY0aefp2CBn9VgzgES27sRR2gOvFK+0CNx/BW0ggOESp6fqQ==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz", + "integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==", "peerDependencies": { "react": "*" } @@ -6448,12 +6489,24 @@ } } }, - "node_modules/react-router": { - "version": "6.22.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", - "integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==", + "node_modules/react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", "dependencies": { - "@remix-run/router": "1.15.3" + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "peerDependencies": { + "react": ">= 16.3" + } + }, + "node_modules/react-router": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.0.tgz", + "integrity": "sha512-wPMZ8S2TuPadH0sF5irFGjkNLIcRvOSaEe7v+JER8508dyJumm6XZB1u5kztlX0RVq6AzRVndzqcUh6sFIauzA==", + "dependencies": { + "@remix-run/router": "1.16.0" }, "engines": { "node": ">=14.0.0" @@ -6463,12 +6516,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.22.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.3.tgz", - "integrity": "sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.23.0.tgz", + "integrity": "sha512-Q9YaSYvubwgbal2c9DJKfx6hTNoBp3iJDsl+Duva/DwxoJH+OTXkxGpql4iUK2sla/8z4RpjAm6EWx1qUDuopQ==", "dependencies": { - "@remix-run/router": "1.15.3", - "react-router": "6.22.3" + "@remix-run/router": "1.16.0", + "react-router": "6.23.0" }, "engines": { "node": ">=14.0.0" @@ -6509,26 +6562,16 @@ } }, "node_modules/react-tracked": { - "version": "1.7.14", - "resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-1.7.14.tgz", - "integrity": "sha512-6UMlgQeRAGA+uyYzuQGm7kZB6ZQYFhc7sntgP7Oxwwd6M0Ud/POyb4K3QWT1eXvoifSa80nrAWnXWFGpOvbwkw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-2.0.0.tgz", + "integrity": "sha512-Px8Ms9zhQKzAj3gnwQm6L+sJwzB0uPa8/BgHKOhB8bIuQEgB2iJfryM7GVja9oviiGAa7vtgEBtM+poT1E7V2w==", "dependencies": { - "proxy-compare": "2.6.0", - "use-context-selector": "1.4.4" + "proxy-compare": "^3.0.0", + "use-context-selector": "^2.0.0" }, "peerDependencies": { - "react": ">=16.8.0", - "react-dom": "*", - "react-native": "*", + "react": ">=18.0.0", "scheduler": ">=0.19.0" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } } }, "node_modules/react-transition-group": { @@ -6639,6 +6682,11 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -6898,9 +6946,9 @@ } }, "node_modules/scheduler": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", - "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "dependencies": { "loose-envify": "^1.1.0" } @@ -7669,22 +7717,12 @@ } }, "node_modules/use-context-selector": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.4.tgz", - "integrity": "sha512-pS790zwGxxe59GoBha3QYOwk8AFGp4DN6DOtH+eoqVmgBBRXVx4IlPDhJmmMiNQAgUaLlP+58aqRC3A4rdaSjg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-2.0.0.tgz", + "integrity": "sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==", "peerDependencies": { - "react": ">=16.8.0", - "react-dom": "*", - "react-native": "*", + "react": ">=18.0.0", "scheduler": ">=0.19.0" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } } }, "node_modules/use-sidecar": { @@ -7721,24 +7759,10 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "node_modules/v8-to-istanbul": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", - "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/vaul": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.0.tgz", - "integrity": "sha512-bZSySGbAHiTXmZychprnX/dE0EsSige88xtyyL3/MCRbrFotRPQZo7UdydGXZWw+CKbNOw5Ow8gwAo93/nB/Cg==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.1.tgz", + "integrity": "sha512-fAhd7i4RNMinx+WEm6pF3nOl78DFkAazcN04ElLPFF9BMCNGbY/kou8UMhIcicm0rJCNePJP0Yyza60gGOD0Jw==", "dependencies": { "@radix-ui/react-dialog": "^1.0.4" }, @@ -7748,9 +7772,9 @@ } }, "node_modules/vite": { - "version": "5.2.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.9.tgz", - "integrity": "sha512-uOQWfuZBlc6Y3W/DTuQ1Sr+oIXWvqljLvS881SVmAj00d5RdgShLcuXWxseWPd4HXwiYBFW/vXHfKFeqj9uQnw==", + "version": "5.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", + "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", "dev": true, "dependencies": { "esbuild": "^0.20.1", @@ -7803,9 +7827,9 @@ } }, "node_modules/vite-node": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.4.0.tgz", - "integrity": "sha512-VZDAseqjrHgNd4Kh8icYHWzTKSCZMhia7GyHfhtzLW33fZlG9SwsB6CEhgyVOWkJfJ2pFLrp/Gj1FSfAiqH9Lw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.0.tgz", + "integrity": "sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==", "dev": true, "dependencies": { "cac": "^6.7.14", @@ -7833,16 +7857,16 @@ } }, "node_modules/vitest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.4.0.tgz", - "integrity": "sha512-gujzn0g7fmwf83/WzrDTnncZt2UiXP41mHuFYFrdwaLRVQ6JYQEiME2IfEjU3vcFL3VKa75XhI3lFgn+hfVsQw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz", + "integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==", "dev": true, "dependencies": { - "@vitest/expect": "1.4.0", - "@vitest/runner": "1.4.0", - "@vitest/snapshot": "1.4.0", - "@vitest/spy": "1.4.0", - "@vitest/utils": "1.4.0", + "@vitest/expect": "1.6.0", + "@vitest/runner": "1.6.0", + "@vitest/snapshot": "1.6.0", + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", @@ -7854,9 +7878,9 @@ "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", - "tinypool": "^0.8.2", + "tinypool": "^0.8.3", "vite": "^5.0.0", - "vite-node": "1.4.0", + "vite-node": "1.6.0", "why-is-node-running": "^2.2.2" }, "bin": { @@ -7871,8 +7895,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.4.0", - "@vitest/ui": "1.4.0", + "@vitest/browser": "1.6.0", + "@vitest/ui": "1.6.0", "happy-dom": "*", "jsdom": "*" }, @@ -8145,9 +8169,9 @@ } }, "node_modules/zod": { - "version": "3.22.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.5.tgz", - "integrity": "sha512-HqnGsCdVZ2xc0qWPLdO25WnseXThh0kEYKIdV5F/hTHO75hNZFp8thxSeHhiPrHZKrFTo1SOgkAj9po5bexZlw==", + "version": "3.23.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.7.tgz", + "integrity": "sha512-NBeIoqbtOiUMomACV/y+V3Qfs9+Okr18vR5c/5pHClPpufWOrsx8TENboDPe265lFdfewX2yBtNTLPvnmCxwog==", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index 3e8fc2907..4638572a6 100644 --- a/web/package.json +++ b/web/package.json @@ -34,31 +34,32 @@ "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "apexcharts": "^3.48.0", + "apexcharts": "^3.49.0", "axios": "^1.6.8", "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", + "clsx": "^2.1.1", "copy-to-clipboard": "^3.3.3", "date-fns": "^3.6.0", "hls.js": "^1.5.8", "idb-keyval": "^6.2.1", - "immer": "^10.0.4", + "immer": "^10.1.1", "konva": "^9.3.6", "lodash": "^4.17.21", - "lucide-react": "^0.372.0", + "lucide-react": "^0.378.0", "monaco-yaml": "^5.1.1", "next-themes": "^0.3.0", - "react": "^18.2.0", + "react": "^18.3.1", "react-apexcharts": "^1.4.1", "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", - "react-dom": "^18.2.0", - "react-hook-form": "^7.51.3", - "react-icons": "^5.1.0", + "react-dom": "^18.3.1", + "react-grid-layout": "^1.4.4", + "react-hook-form": "^7.51.4", + "react-icons": "^5.2.1", "react-konva": "^18.2.10", - "react-router-dom": "^6.22.3", + "react-router-dom": "^6.23.0", "react-swipeable": "^7.0.1", - "react-tracked": "^1.7.14", + "react-tracked": "^2.0.0", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.8.1", "react-zoom-pan-pinch": "^3.4.4", @@ -70,24 +71,25 @@ "swr": "^2.2.5", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.0", + "vaul": "^0.9.1", "vite-plugin-monaco-editor": "^1.1.0", - "zod": "^3.22.5" + "zod": "^3.23.7" }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", "@testing-library/jest-dom": "^6.1.5", - "@types/lodash": "^4.17.0", - "@types/node": "^20.12.7", - "@types/react": "^18.2.79", - "@types/react-dom": "^18.2.25", + "@types/lodash": "^4.17.1", + "@types/node": "^20.12.11", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "@types/react-grid-layout": "^1.3.5", "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", "@types/strftime": "^0.9.8", "@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", + "@vitest/coverage-v8": "^1.6.0", "autoprefixer": "^10.4.19", "eslint": "^8.55.0", "eslint-config-prettier": "^9.1.0", @@ -99,12 +101,12 @@ "fake-indexeddb": "^5.0.2", "jest-websocket-mock": "^2.5.0", "jsdom": "^24.0.0", - "msw": "^2.2.14", + "msw": "^2.3.0", "postcss": "^8.4.38", "prettier": "^3.2.5", "tailwindcss": "^3.4.3", "typescript": "^5.4.5", - "vite": "^5.2.9", - "vitest": "^1.4.0" + "vite": "^5.2.11", + "vitest": "^1.6.0" } } diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 2439dbb33..34fe4b2f0 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -2,7 +2,12 @@ import { baseUrl } from "./baseUrl"; import { useCallback, useEffect, useState } from "react"; import useWebSocket, { ReadyState } from "react-use-websocket"; import { FrigateConfig } from "@/types/frigateConfig"; -import { FrigateEvent, FrigateReview, ToggleableSetting } from "@/types/ws"; +import { + FrigateCameraState, + FrigateEvent, + FrigateReview, + ToggleableSetting, +} from "@/types/ws"; import { FrigateStats } from "@/types/stats"; import useSWR from "swr"; import { createContainer } from "react-tracked"; @@ -60,7 +65,13 @@ function useValue(): useValueReturn { setWsState({ ...wsState, [data.topic]: data.payload }); } }, - onOpen: () => {}, + onOpen: () => { + sendJsonMessage({ + topic: "onConnect", + message: "", + retain: false, + }); + }, shouldReconnect: () => true, }); @@ -193,6 +204,16 @@ export function useFrigateStats(): { payload: FrigateStats } { return { payload: JSON.parse(payload as string) }; } +export function useInitialCameraState(camera: string): { + payload: FrigateCameraState; +} { + const { + value: { payload }, + } = useWs("camera_activity", ""); + const data = JSON.parse(payload as string); + return { payload: data ? data[camera] : undefined }; +} + export function useMotionActivity(camera: string): { payload: string } { const { value: { payload }, diff --git a/web/src/components/Logo.tsx b/web/src/components/Logo.tsx index 32cd52eda..ca2897ac4 100644 --- a/web/src/components/Logo.tsx +++ b/web/src/components/Logo.tsx @@ -1,9 +1,11 @@ +import { cn } from "@/lib/utils"; + type LogoProps = { className?: string; }; export default function Logo({ className }: LogoProps) { return ( - + ); diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx index 88d00d3b7..c2e81cf90 100644 --- a/web/src/components/Statusbar.tsx +++ b/web/src/components/Statusbar.tsx @@ -9,6 +9,7 @@ import { useContext, useEffect, useMemo } from "react"; import { FaCheck } from "react-icons/fa"; import { IoIosWarning } from "react-icons/io"; import { MdCircle } from "react-icons/md"; +import { Link } from "react-router-dom"; import useSWR from "swr"; export default function Statusbar() { @@ -43,7 +44,13 @@ export default function Statusbar() { useEffect(() => { clearMessages("stats"); potentialProblems.forEach((problem) => { - addMessage("stats", problem.text, problem.color); + addMessage( + "stats", + problem.text, + problem.color, + undefined, + problem.relevantLink, + ); }); }, [potentialProblems, addMessage, clearMessages]); @@ -51,18 +58,20 @@ export default function Statusbar() {
{cpuPercent && ( -
- - CPU {cpuPercent}% -
+ +
+ + CPU {cpuPercent}% +
+ )} {Object.entries(stats?.gpu_usages || {}).map(([name, stats]) => { if (name == "error-gpu") { @@ -86,18 +95,24 @@ export default function Statusbar() { const gpu = parseInt(stats.gpu); return ( -
- - {gpuTitle} {gpu}% -
+ + {" "} +
+ + {gpuTitle} {gpu}% +
+ ); })}
@@ -110,14 +125,29 @@ export default function Statusbar() { ) : ( Object.entries(messages).map(([key, messageArray]) => (
- {messageArray.map(({ id, text, color }: StatusMessage) => ( -
- - {text} -
- ))} + {messageArray.map(({ id, text, color, link }: StatusMessage) => { + const message = ( +
+ + {text} +
+ ); + + if (link) { + return ( + + {message} + + ); + } else { + return message; + } + })}
)) )} diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index ab0bfdf08..b4fb707bc 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -2,6 +2,7 @@ import { useApiHost } from "@/api"; import { useEffect, useRef, useState } from "react"; import useSWR from "swr"; import ActivityIndicator from "../indicators/activity-indicator"; +import { useResizeObserver } from "@/hooks/resize-observer"; type CameraImageProps = { className?: string; @@ -24,6 +25,7 @@ export default function CameraImage({ const { name } = config ? config.cameras[camera] : ""; const enabled = config ? config.cameras[camera].enabled : "True"; + const [isPortraitImage, setIsPortraitImage] = useState(false); useEffect(() => { if (!config || !imgRef.current) { @@ -35,15 +37,25 @@ export default function CameraImage({ }`; }, [apiHost, name, imgRef, searchParams, config]); + const [{ width: containerWidth, height: containerHeight }] = + useResizeObserver(containerRef); + return (
{enabled ? ( { setHasLoaded(true); + if (imgRef.current) { + const { naturalHeight, naturalWidth } = imgRef.current; + setIsPortraitImage( + naturalWidth / naturalHeight < containerWidth / containerHeight, + ); + } + if (onload) { onload(); } diff --git a/web/src/components/camera/ResizingCameraImage.tsx b/web/src/components/camera/ResizingCameraImage.tsx index f19c40ff3..47b25124e 100644 --- a/web/src/components/camera/ResizingCameraImage.tsx +++ b/web/src/components/camera/ResizingCameraImage.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import ActivityIndicator from "../indicators/activity-indicator"; import { useResizeObserver } from "@/hooks/resize-observer"; +import { cn } from "@/lib/utils"; type CameraImageProps = { className?: string; @@ -95,7 +96,7 @@ export default function CameraImage({ return (
{enabled ? ( diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index dddbcdb77..125b6dada 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -3,16 +3,16 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { useCallback, useMemo } from "react"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; -import { ReviewSegment } from "@/types/review"; +import { REVIEW_PADDING, ReviewSegment } from "@/types/review"; import { useNavigate } from "react-router-dom"; import { RecordingStartingPoint } from "@/types/record"; import axios from "axios"; -import { Preview } from "@/types/preview"; import { InProgressPreview, VideoPreview, } from "../player/PreviewThumbnailPlayer"; import { isCurrentHour } from "@/utils/dateUtil"; +import { useCameraPreviews } from "@/hooks/use-camera-previews"; type AnimatedEventCardProps = { event: ReviewSegment; @@ -24,10 +24,15 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) { // preview - const { data: previews } = useSWR( - currentHour - ? null - : `/preview/${event.camera}/start/${Math.round(event.start_time)}/end/${Math.round(event.end_time || event.start_time + 20)}`, + const previews = useCameraPreviews( + { + after: Math.round(event.start_time), + before: Math.round(event.end_time || event.start_time + 20), + }, + { + camera: event.camera, + fetchPreviews: !currentHour, + }, ); // interaction @@ -39,7 +44,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) { severity: event.severity, recording: { camera: event.camera, - startTime: event.start_time, + startTime: event.start_time - REVIEW_PADDING, severity: event.severity, } as RecordingStartingPoint, }, @@ -62,7 +67,7 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
setHovered(true) diff --git a/web/src/components/dynamic/CameraFeatureToggle.tsx b/web/src/components/dynamic/CameraFeatureToggle.tsx index b0418c556..a31bafb9d 100644 --- a/web/src/components/dynamic/CameraFeatureToggle.tsx +++ b/web/src/components/dynamic/CameraFeatureToggle.tsx @@ -5,6 +5,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { isDesktop } from "react-device-detect"; +import { cn } from "@/lib/utils"; const variants = { primary: { @@ -38,9 +39,11 @@ export default function CameraFeatureToggle({ const content = (
+ <> - - - - - - - All Cameras - - - {groups.map(([name, config]) => { - return ( - + +
+ - - {name} - + + + All Cameras + + - ); - })} - {isDesktop && ( - - )} -
+ {groups.map(([name, config]) => { + return ( + + + + + + + {name} + + + + ); + })} + + + {isMobile && } +
+ + ); } @@ -142,195 +194,487 @@ type NewGroupDialogProps = { open: boolean; setOpen: (open: boolean) => void; currentGroups: [string, CameraGroupConfig][]; + activeGroup?: string; + setGroup: (value: string | undefined, replace?: boolean | undefined) => void; + deleteGroup: () => void; }; -function NewGroupDialog({ open, setOpen, currentGroups }: NewGroupDialogProps) { +function NewGroupDialog({ + open, + setOpen, + currentGroups, + activeGroup, + setGroup, + deleteGroup, +}: NewGroupDialogProps) { + const { mutate: updateConfig } = useSWR("config"); + + // editing group and state + + const [editingGroupName, setEditingGroupName] = useState(""); + + const editingGroup = useMemo(() => { + if (currentGroups && editingGroupName !== undefined) { + return currentGroups.find( + ([groupName]) => groupName === editingGroupName, + ); + } else { + return undefined; + } + }, [currentGroups, editingGroupName]); + + const [editState, setEditState] = useState<"none" | "add" | "edit">("none"); + const [isLoading, setIsLoading] = useState(false); + + const [, , , deleteGridLayout] = usePersistence( + `${activeGroup}-draggable-layout`, + ); + + // callbacks + + const onDeleteGroup = useCallback( + async (name: string) => { + deleteGridLayout(); + deleteGroup(); + + await axios + .put(`config/set?camera_groups.${name}`, { requires_restart: 0 }) + .then((res) => { + if (res.status === 200) { + if (activeGroup == name) { + // deleting current group + setGroup("default"); + } + updateConfig(); + } else { + setOpen(false); + setEditState("none"); + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + setOpen(false); + setEditState("none"); + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [ + updateConfig, + activeGroup, + setGroup, + setOpen, + deleteGroup, + deleteGridLayout, + ], + ); + + const onSave = () => { + setOpen(false); + setEditState("none"); + }; + + const onCancel = () => { + setEditingGroupName(""); + setEditState("none"); + }; + + const onEditGroup = useCallback((group: [string, CameraGroupConfig]) => { + setEditingGroupName(group[0]); + setEditState("edit"); + }, []); + + const Overlay = isDesktop ? Dialog : Drawer; + const Content = isDesktop ? DialogContent : DrawerContent; + + return ( + <> + + { + setEditState("none"); + setOpen(open); + }} + > + +
+ {editState === "none" && ( + <> +
+ Camera Groups + +
+ {currentGroups.map((group) => ( + onDeleteGroup(group[0])} + onEditGroup={() => onEditGroup(group)} + /> + ))} + + )} + + {editState != "none" && ( + <> +
+ + {editState == "add" ? "Add" : "Edit"} Camera Group + +
+ + + )} +
+
+
+ + ); +} + +type CameraGroupRowProps = { + group: [string, CameraGroupConfig]; + onDeleteGroup: () => void; + onEditGroup: () => void; +}; + +export function CameraGroupRow({ + group, + onDeleteGroup, + onEditGroup, +}: CameraGroupRowProps) { + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + if (!group) { + return; + } + + return ( + <> +
+
+

{group[0]}

+
+ setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Are you sure you want to delete the camera group{" "} + {group[0]}? + + + Cancel + + Delete + + + + + + {isMobile && ( + <> + + + + + + Edit + setDeleteDialogOpen(true)}> + Delete + + + + + )} + {!isMobile && ( +
+ + + + + Edit + + + + + setDeleteDialogOpen(true)} + /> + + Delete + +
+ )} +
+ + ); +} + +type CameraGroupEditProps = { + currentGroups: [string, CameraGroupConfig][]; + editingGroup?: [string, CameraGroupConfig]; + isLoading: boolean; + setIsLoading: React.Dispatch>; + onSave?: () => void; + onCancel?: () => void; +}; + +export function CameraGroupEdit({ + currentGroups, + editingGroup, + isLoading, + setIsLoading, + onSave, + onCancel, +}: CameraGroupEditProps) { const { data: config, mutate: updateConfig } = useSWR("config"); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); - // add fields + const formSchema = z.object({ + name: z + .string() + .min(2, { + message: "Camera group name must be at least 2 characters.", + }) + .transform((val: string) => val.trim().replace(/\s+/g, "_")) + .refine( + (value: string) => { + return ( + editingGroup !== undefined || + !currentGroups.map((group) => group[0]).includes(value) + ); + }, + { + message: "Camera group name already exists.", + }, + ) + .refine((value: string) => value.toLowerCase() !== "default", { + message: "Invalid camera group name.", + }), - const [editState, setEditState] = useState<"none" | "add" | "edit">("none"); - const [newTitle, setNewTitle] = useState(""); - const [icon, setIcon] = useState(""); - const [cameras, setCameras] = useState([]); + cameras: z.array(z.string()).min(2, { + message: "You must select at least two cameras.", + }), + icon: z + .string() + .min(1, { message: "You must select an icon." }) + .refine((value) => Object.keys(LuIcons).includes(value), { + message: "Invalid icon", + }), + }); - // validation - - const [error, setError] = useState(""); - - const onCreateGroup = useCallback(async () => { - if (!newTitle) { - setError("A title must be selected"); - return; - } - - if (!icon) { - setError("An icon must be selected"); - return; - } - - if (!cameras || cameras.length < 2) { - setError("At least 2 cameras must be selected"); - return; - } - - setError(""); - const orderQuery = `camera_groups.${newTitle}.order=${currentGroups.length}`; - const iconQuery = `camera_groups.${newTitle}.icon=${icon}`; - const cameraQueries = cameras - .map((cam) => `&camera_groups.${newTitle}.cameras=${cam}`) - .join(""); - - const req = axios.put( - `config/set?${orderQuery}&${iconQuery}${cameraQueries}`, - { requires_restart: 0 }, - ); - - setOpen(false); - - if ((await req).status == 200) { - setNewTitle(""); - setIcon(""); - setCameras([]); - updateConfig(); - } - }, [currentGroups, cameras, newTitle, icon, setOpen, updateConfig]); - - const onDeleteGroup = useCallback( - async (name: string) => { - const req = axios.put(`config/set?camera_groups.${name}`, { - requires_restart: 0, - }); - - if ((await req).status == 200) { - updateConfig(); + const onSubmit = useCallback( + async (values: z.infer) => { + if (!values) { + return; } + + setIsLoading(true); + + const order = + editingGroup === undefined + ? currentGroups.length + 1 + : editingGroup[1].order; + + const orderQuery = `camera_groups.${values.name}.order=${+order}`; + const iconQuery = `camera_groups.${values.name}.icon=${values.icon}`; + const cameraQueries = values.cameras + .map((cam) => `&camera_groups.${values.name}.cameras=${cam}`) + .join(""); + + axios + .put(`config/set?${orderQuery}&${iconQuery}${cameraQueries}`, { + requires_restart: 0, + }) + .then((res) => { + if (res.status === 200) { + toast.success(`Camera group (${values.name}) has been saved.`, { + position: "top-center", + }); + updateConfig(); + if (onSave) { + onSave(); + } + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); }, - [updateConfig], + [currentGroups, setIsLoading, onSave, updateConfig, editingGroup], ); + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onSubmit", + defaultValues: { + name: (editingGroup && editingGroup[0]) ?? "", + icon: editingGroup && (editingGroup[1].icon as IconName), + cameras: editingGroup && editingGroup[1].cameras, + }, + }); + return ( - { - setEditState("none"); - setNewTitle(""); - setIcon(""); - setCameras([]); - setOpen(open); - }} - > - - Camera Groups - {currentGroups.map((group) => ( -
- {group[0]} -
- - -
-
- ))} - {currentGroups.length > 0 && } - {editState == "none" && ( - - )} - {editState != "none" && ( - <> - setNewTitle(e.target.value)} - /> - - -
- {icon.length == 0 ? "Select Icon" : "Icon: "} - {icon ? getIconForGroup(icon) :
} -
- - - - {GROUP_ICONS.map((gIcon) => ( - - {getIconForGroup(gIcon)} - {gIcon} - - ))} - - - - - -
- {cameras.length == 0 - ? "Select Cameras" - : `${cameras.length} Cameras`} -
-
- +
+ + ( + + Name + + + + + + )} + /> + + +
+ ( + + Cameras + + Select cameras for this group. + {[ ...(birdseyeConfig?.enabled ? ["birdseye"] : []), ...Object.keys(config?.cameras ?? {}), ].map((camera) => ( - { - if (checked) { - setCameras([...cameras, camera]); - } else { - const index = cameras.indexOf(camera); - setCameras([ - ...cameras.slice(0, index), - ...cameras.slice(index + 1), - ]); - } - }} - /> + + { + const updatedCameras = checked + ? [...(field.value || []), camera] + : (field.value || []).filter((c) => c !== camera); + form.setValue("cameras", updatedCameras); + }} + /> + ))} - - - {error &&
{error}
} - - - )} - -
+ + + )} + /> +
+ + + ( + + Icon + + { + field.onChange(newIcon?.name ?? undefined); + }} + /> + + + + )} + /> + + + +
+ + +
+ + ); } diff --git a/web/src/components/filter/FilterSwitch.tsx b/web/src/components/filter/FilterSwitch.tsx index 8af6d6ce3..288e3f64a 100644 --- a/web/src/components/filter/FilterSwitch.tsx +++ b/web/src/components/filter/FilterSwitch.tsx @@ -3,24 +3,27 @@ import { Label } from "../ui/label"; type FilterSwitchProps = { label: string; + disabled?: boolean; isChecked: boolean; onCheckedChange: (checked: boolean) => void; }; export default function FilterSwitch({ label, + disabled = false, isChecked, onCheckedChange, }: FilterSwitchProps) { return (
diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index 2248835a2..ab597c85a 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -10,7 +10,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { ReviewFilter, ReviewSummary } from "@/types/review"; +import { ReviewFilter, ReviewSeverity, ReviewSummary } from "@/types/review"; import { getEndOfDayTimestamp } from "@/utils/dateUtil"; import { useFormattedTimestamp } from "@/hooks/use-date-utils"; import { @@ -49,19 +49,21 @@ const DEFAULT_REVIEW_FILTERS: ReviewFilters[] = [ type ReviewFilterGroupProps = { filters?: ReviewFilters[]; + currentSeverity?: ReviewSeverity; reviewSummary?: ReviewSummary; filter?: ReviewFilter; - onUpdateFilter: (filter: ReviewFilter) => void; motionOnly: boolean; + onUpdateFilter: (filter: ReviewFilter) => void; setMotionOnly: React.Dispatch>; }; export default function ReviewFilterGroup({ filters = DEFAULT_REVIEW_FILTERS, + currentSeverity, reviewSummary, filter, - onUpdateFilter, motionOnly, + onUpdateFilter, setMotionOnly, }: ReviewFilterGroupProps) { const { data: config } = useSWR("config"); @@ -179,6 +181,11 @@ export default function ReviewFilterGroup({ { + onUpdateFilter({ ...filter, showAll }); + }} updateLabelFilter={(newLabels) => { onUpdateFilter({ ...filter, labels: newLabels }); }} @@ -188,6 +195,7 @@ export default function ReviewFilterGroup({ )} -
+
void; updateLabelFilter: (labels: string[] | undefined) => void; }; function GeneralFilterButton({ allLabels, selectedLabels, + currentSeverity, + showAll, + setShowAll, updateLabelFilter, }: GeneralFilterButtonProps) { const [open, setOpen] = useState(false); @@ -510,6 +524,9 @@ function GeneralFilterButton({ allLabels={allLabels} selectedLabels={selectedLabels} currentLabels={currentLabels} + currentSeverity={currentSeverity} + showAll={showAll} + setShowAll={setShowAll} updateLabelFilter={updateLabelFilter} setCurrentLabels={setCurrentLabels} onClose={() => setOpen(false)} @@ -557,6 +574,9 @@ type GeneralFilterContentProps = { allLabels: string[]; selectedLabels: string[] | undefined; currentLabels: string[] | undefined; + currentSeverity?: ReviewSeverity; + showAll?: boolean; + setShowAll?: (showAll: boolean) => void; updateLabelFilter: (labels: string[] | undefined) => void; setCurrentLabels: (labels: string[] | undefined) => void; onClose: () => void; @@ -565,13 +585,35 @@ export function GeneralFilterContent({ allLabels, selectedLabels, currentLabels, + currentSeverity, + showAll, + setShowAll, updateLabelFilter, setCurrentLabels, onClose, }: GeneralFilterContentProps) { return ( <> -
+
+ {currentSeverity && setShowAll && ( +
+ + + +
+ )}
+
); diff --git a/web/src/components/settings/MasksAndZones.tsx b/web/src/components/settings/MasksAndZones.tsx index 84999da8f..66eb33952 100644 --- a/web/src/components/settings/MasksAndZones.tsx +++ b/web/src/components/settings/MasksAndZones.tsx @@ -171,13 +171,19 @@ export default function MasksAndZones({ setActivePolygonIndex(undefined); setHoveredPolygonIndex(null); setUnsavedChanges(false); + document.title = "Mask and Zone Editor - Frigate"; }, [allPolygons, setUnsavedChanges]); const handleSave = useCallback(() => { setAllPolygons([...(editingPolygons ?? [])]); setHoveredPolygonIndex(null); setUnsavedChanges(false); - addMessage("masks_zones", "Restart required (masks/zones changed)"); + addMessage( + "masks_zones", + "Restart required (masks/zones changed)", + undefined, + "masks_zones", + ); }, [editingPolygons, setUnsavedChanges, addMessage]); useEffect(() => { @@ -353,6 +359,10 @@ export default function MasksAndZones({ } }, [selectedCamera]); + useEffect(() => { + document.title = "Mask and Zone Editor - Frigate"; + }, []); + if (!cameraConfig && !selectedCamera) { return ; } @@ -361,7 +371,7 @@ export default function MasksAndZones({ <> {cameraConfig && editingPolygons && (
- +
{editPane == "zone" && ( { + document.title = "Edit Motion Mask - Frigate"; + }, []); + if (!polygon) { return; } return ( <> - + {polygon.name.length ? "Edit" : "New"} Motion Mask diff --git a/web/src/components/settings/MotionTuner.tsx b/web/src/components/settings/MotionTuner.tsx index 02350b070..b837e7ab6 100644 --- a/web/src/components/settings/MotionTuner.tsx +++ b/web/src/components/settings/MotionTuner.tsx @@ -153,19 +153,28 @@ export default function MotionTuner({ useEffect(() => { if (changedValue) { - addMessage("motion_tuner", "Unsaved motion tuner changes"); + addMessage( + "motion_tuner", + "Unsaved motion tuner changes", + undefined, + "motion_tuner", + ); } else { clearMessages("motion_tuner"); } }, [changedValue, addMessage, clearMessages]); + useEffect(() => { + document.title = "Motion Tuner - Frigate"; + }, []); + if (!cameraConfig && !selectedCamera) { return ; } return (
- +
Motion Detection Tuner diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx index ae755c48f..d97297b98 100644 --- a/web/src/components/settings/ObjectMaskEditPane.tsx +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -19,7 +19,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -235,13 +235,17 @@ export default function ObjectMaskEditPane({ } } + useEffect(() => { + document.title = "Edit Object Mask - Frigate"; + }, []); + if (!polygon) { return; } return ( <> - + {polygon.name.length ? "Edit" : "New"} Object Mask diff --git a/web/src/components/settings/ObjectSettings.tsx b/web/src/components/settings/ObjectSettings.tsx index 4b45c1fa4..c3ced115b 100644 --- a/web/src/components/settings/ObjectSettings.tsx +++ b/web/src/components/settings/ObjectSettings.tsx @@ -1,31 +1,284 @@ -import { useMemo } from "react"; -import DebugCameraImage from "../camera/DebugCameraImage"; -import { FrigateConfig } from "@/types/frigateConfig"; +import { useCallback, useEffect, useMemo } from "react"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage"; +import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; +import { Toaster } from "@/components/ui/sonner"; +import { Label } from "@/components/ui/label"; import useSWR from "swr"; -import ActivityIndicator from "../indicators/activity-indicator"; +import Heading from "../ui/heading"; +import { Switch } from "../ui/switch"; +import { usePersistence } from "@/hooks/use-persistence"; +import { Skeleton } from "../ui/skeleton"; +import { useCameraActivity } from "@/hooks/use-camera-activity"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import { ObjectType } from "@/types/ws"; +import useDeepMemo from "@/hooks/use-deep-memo"; +import { Card } from "../ui/card"; +import { getIconForLabel } from "@/utils/iconUtil"; +import { capitalizeFirstLetter } from "@/utils/stringUtil"; type ObjectSettingsProps = { selectedCamera?: string; }; +type Options = { [key: string]: boolean }; + +const emptyObject = Object.freeze({}); + export default function ObjectSettings({ selectedCamera, }: ObjectSettingsProps) { const { data: config } = useSWR("config"); + const DEBUG_OPTIONS = [ + { + param: "bbox", + title: "Bounding boxes", + description: "Show bounding boxes around detected objects", + }, + { + param: "timestamp", + title: "Timestamp", + description: "Overlay a timestamp on the image", + }, + { + param: "zones", + title: "Zones", + description: "Show an outline of any defined zones", + }, + { + param: "mask", + title: "Motion masks", + description: "Show motion mask polygons", + }, + { + param: "motion", + title: "Motion boxes", + description: "Show boxes around areas where motion is detected", + }, + { + param: "regions", + title: "Regions", + description: + "Show a box of the region of interest sent to the object detector", + }, + ]; + + const [options, setOptions, optionsLoaded] = usePersistence( + `${selectedCamera}-feed`, + emptyObject, + ); + + const handleSetOption = useCallback( + (id: string, value: boolean) => { + const newOptions = { ...options, [id]: value }; + setOptions(newOptions); + }, + [options, setOptions], + ); + const cameraConfig = useMemo(() => { if (config && selectedCamera) { return config.cameras[selectedCamera]; } }, [config, selectedCamera]); + const { objects } = useCameraActivity(cameraConfig ?? ({} as CameraConfig)); + + const memoizedObjects = useDeepMemo(objects); + + const searchParams = useMemo(() => { + if (!optionsLoaded) { + return new URLSearchParams(); + } + + const params = new URLSearchParams( + Object.keys(options || {}).reduce((memo, key) => { + //@ts-expect-error we know this is correct + memo.push([key, options[key] === true ? "1" : "0"]); + return memo; + }, []), + ); + return params; + }, [options, optionsLoaded]); + + useEffect(() => { + document.title = "Object Settings - Frigate"; + }, []); + if (!cameraConfig) { return ; } return ( -
- +
+ +
+ + Debug + +
+

+ Frigate uses your detectors{" "} + {config + ? "(" + + Object.keys(config?.detectors) + .map((detector) => capitalizeFirstLetter(detector)) + .join(",") + + ")" + : ""}{" "} + to detect objects in your camera's video stream. +

+

+ Debugging view shows a real-time view of detected objects and their + statistics. The object list shows a time-delayed summary of detected + objects. +

+
+ + + + Debugging + Object List + + +
+
+
+ {DEBUG_OPTIONS.map(({ param, title, description }) => ( +
+
+ +
+ {description} +
+
+ { + handleSetOption(param, isChecked); + }} + /> +
+ ))} +
+
+
+
+ + {ObjectList(memoizedObjects)} + +
+
+ + {cameraConfig ? ( +
+
+ +
+
+ ) : ( + + )} +
+ ); +} + +function ObjectList(objects?: ObjectType[]) { + const { data: config } = useSWR("config"); + + const colormap = useMemo(() => { + if (!config) { + return; + } + + return config.model?.colormap; + }, [config]); + + const getColorForObjectName = useCallback( + (objectName: string) => { + return colormap && colormap[objectName] + ? `rgb(${colormap[objectName][2]}, ${colormap[objectName][1]}, ${colormap[objectName][0]})` + : "rgb(128, 128, 128)"; + }, + [colormap], + ); + + return ( +
+ {objects && objects.length > 0 ? ( + objects.map((obj) => { + return ( + +
+
+
+ {getIconForLabel(obj.label, "size-5 text-white")} +
+
+ {capitalizeFirstLetter(obj.label)} +
+
+
+
+
+

+ Score +

+ {obj.score + ? (obj.score * 100).toFixed(1).toString() + : "-"} + % +
+
+
+
+

+ Ratio +

+ {obj.ratio ? obj.ratio.toFixed(2).toString() : "-"} +
+
+
+
+

+ Area +

+ {obj.area ? obj.area.toString() : "-"} +
+
+
+
+
+ ); + }) + ) : ( +
No objects
+ )}
); } diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx index d21ef01bf..bea79ed2c 100644 --- a/web/src/components/settings/PolygonCanvas.tsx +++ b/web/src/components/settings/PolygonCanvas.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useState, useEffect } from "react"; +import React, { useMemo, useRef, useState, useEffect, RefObject } from "react"; import PolygonDrawer from "./PolygonDrawer"; import { Stage, Layer, Image } from "react-konva"; import Konva from "konva"; @@ -7,6 +7,7 @@ import { Polygon, PolygonType } from "@/types/canvas"; import { useApiHost } from "@/api"; type PolygonCanvasProps = { + containerRef: RefObject; camera: string; width: number; height: number; @@ -18,6 +19,7 @@ type PolygonCanvasProps = { }; export function PolygonCanvas({ + containerRef, camera, width, height, @@ -55,10 +57,6 @@ export function PolygonCanvas({ }; }, [videoElement]); - const getMousePos = (stage: Konva.Stage) => { - return [stage.getPointerPosition()!.x, stage.getPointerPosition()!.y]; - }; - const addPointToPolygon = (polygon: Polygon, newPoint: number[]) => { const points = polygon.points; const pointsOrder = polygon.pointsOrder; @@ -99,37 +97,6 @@ export function PolygonCanvas({ return { updatedPoints, updatedPointsOrder }; }; - const isMouseOverFirstPoint = (polygon: Polygon, mousePos: number[]) => { - if (!polygon || !polygon.points || polygon.points.length < 1) { - return false; - } - const [firstPoint] = polygon.points; - const distance = Math.hypot( - mousePos[0] - firstPoint[0], - mousePos[1] - firstPoint[1], - ); - return distance < 10; - }; - - const isMouseOverAnyPoint = (polygon: Polygon, mousePos: number[]) => { - if (!polygon || !polygon.points || polygon.points.length === 0) { - return false; - } - - for (let i = 1; i < polygon.points.length; i++) { - const point = polygon.points[i]; - const distance = Math.hypot( - mousePos[0] - point[0], - mousePos[1] - point[1], - ); - if (distance < 10) { - return true; - } - } - - return false; - }; - const handleMouseDown = (e: KonvaEventObject) => { if (activePolygonIndex === undefined || !polygons) { return; @@ -138,11 +105,13 @@ export function PolygonCanvas({ const updatedPolygons = [...polygons]; const activePolygon = updatedPolygons[activePolygonIndex]; const stage = e.target.getStage()!; - const mousePos = getMousePos(stage); + const mousePos = stage.getPointerPosition() ?? { x: 0, y: 0 }; + const intersection = stage.getIntersection(mousePos); if ( activePolygon.points.length >= 3 && - isMouseOverFirstPoint(activePolygon, mousePos) + intersection?.getClassName() == "Circle" && + intersection?.name() == "point-0" ) { // Close the polygon updatedPolygons[activePolygonIndex] = { @@ -152,12 +121,13 @@ export function PolygonCanvas({ setPolygons(updatedPolygons); } else { if ( - !activePolygon.isFinished && - !isMouseOverAnyPoint(activePolygon, mousePos) + (!activePolygon.isFinished && + intersection?.getClassName() !== "Circle") || + (activePolygon.isFinished && intersection?.name() == "unfilled-line") ) { const { updatedPoints, updatedPointsOrder } = addPointToPolygon( activePolygon, - mousePos, + [mousePos.x, mousePos.y], ); updatedPolygons[activePolygonIndex] = { @@ -168,62 +138,6 @@ export function PolygonCanvas({ setPolygons(updatedPolygons); } } - // } - }; - - const handleMouseOverStartPoint = ( - e: KonvaEventObject, - ) => { - if (activePolygonIndex === undefined || !polygons) { - return; - } - - const activePolygon = polygons[activePolygonIndex]; - if (!activePolygon.isFinished && activePolygon.points.length >= 3) { - e.target.getStage()!.container().style.cursor = "default"; - e.currentTarget.scale({ x: 2, y: 2 }); - } - }; - - const handleMouseOutStartPoint = ( - e: KonvaEventObject, - ) => { - e.currentTarget.scale({ x: 1, y: 1 }); - - if (activePolygonIndex === undefined || !polygons) { - return; - } - - const activePolygon = polygons[activePolygonIndex]; - if ( - (!activePolygon.isFinished && activePolygon.points.length >= 3) || - activePolygon.isFinished - ) { - e.currentTarget.scale({ x: 1, y: 1 }); - } - }; - - const handleMouseOverAnyPoint = ( - e: KonvaEventObject, - ) => { - if (!polygons) { - return; - } - e.target.getStage()!.container().style.cursor = "move"; - }; - - const handleMouseOutAnyPoint = ( - e: KonvaEventObject, - ) => { - if (activePolygonIndex === undefined || !polygons) { - return; - } - const activePolygon = polygons[activePolygonIndex]; - if (activePolygon.isFinished) { - e.target.getStage()!.container().style.cursor = "default"; - } else { - e.target.getStage()!.container().style.cursor = "crosshair"; - } }; const handlePointDragMove = ( @@ -237,7 +151,8 @@ export function PolygonCanvas({ const activePolygon = updatedPolygons[activePolygonIndex]; const stage = e.target.getStage(); if (stage) { - const index = e.target.index - 1; + // we add an unfilled line for adding points when finished + const index = e.target.index - (activePolygon.isFinished ? 2 : 1); const pos = [e.target._lastPos!.x, e.target._lastPos!.y]; if (pos[0] < 0) pos[0] = 0; if (pos[1] < 0) pos[1] = 0; @@ -272,26 +187,17 @@ export function PolygonCanvas({ } }; - const handleStageMouseOver = ( - e: Konva.KonvaEventObject, - ) => { + const handleStageMouseOver = () => { if (activePolygonIndex === undefined || !polygons) { return; } const updatedPolygons = [...polygons]; const activePolygon = updatedPolygons[activePolygonIndex]; - const stage = e.target.getStage()!; - const mousePos = getMousePos(stage); - if ( - activePolygon.isFinished || - isMouseOverAnyPoint(activePolygon, mousePos) || - isMouseOverFirstPoint(activePolygon, mousePos) - ) - return; - - e.target.getStage()!.container().style.cursor = "crosshair"; + if (containerRef.current && !activePolygon.isFinished) { + containerRef.current.style.cursor = "crosshair"; + } }; useEffect(() => { @@ -336,6 +242,7 @@ export function PolygonCanvas({ selectedZoneMask.includes(polygon.type)) && index !== activePolygonIndex && ( ), )} @@ -356,6 +259,7 @@ export function PolygonCanvas({ (selectedZoneMask === undefined || selectedZoneMask.includes(polygons[activePolygonIndex].type)) && ( )} diff --git a/web/src/components/settings/PolygonDrawer.tsx b/web/src/components/settings/PolygonDrawer.tsx index 99d05888e..706cbea9d 100644 --- a/web/src/components/settings/PolygonDrawer.tsx +++ b/web/src/components/settings/PolygonDrawer.tsx @@ -1,4 +1,11 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { + RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Line, Circle, Group } from "react-konva"; import { minMax, @@ -11,6 +18,7 @@ import Konva from "konva"; import { Vector2d } from "konva/lib/types"; type PolygonDrawerProps = { + stageRef: RefObject; points: number[][]; isActive: boolean; isHovered: boolean; @@ -18,21 +26,10 @@ type PolygonDrawerProps = { color: number[]; handlePointDragMove: (e: KonvaEventObject) => void; handleGroupDragEnd: (e: KonvaEventObject) => void; - handleMouseOverStartPoint: ( - e: KonvaEventObject, - ) => void; - handleMouseOutStartPoint: ( - e: KonvaEventObject, - ) => void; - handleMouseOverAnyPoint: ( - e: KonvaEventObject, - ) => void; - handleMouseOutAnyPoint: ( - e: KonvaEventObject, - ) => void; }; export default function PolygonDrawer({ + stageRef, points, isActive, isHovered, @@ -40,31 +37,41 @@ export default function PolygonDrawer({ color, handlePointDragMove, handleGroupDragEnd, - handleMouseOverStartPoint, - handleMouseOutStartPoint, - handleMouseOverAnyPoint, - handleMouseOutAnyPoint, }: PolygonDrawerProps) { const vertexRadius = 6; const flattenedPoints = useMemo(() => flattenPoints(points), [points]); - const [stage, setStage] = useState(); const [minMaxX, setMinMaxX] = useState([0, 0]); const [minMaxY, setMinMaxY] = useState([0, 0]); const groupRef = useRef(null); + const [cursor, setCursor] = useState("default"); - const handleGroupMouseOver = ( - e: Konva.KonvaEventObject, + const handleMouseOverPoint = ( + e: KonvaEventObject, ) => { - if (!isFinished) return; - e.target.getStage()!.container().style.cursor = "move"; - setStage(e.target.getStage()!); + if (!e.target) return; + + if (!isFinished && points.length >= 3 && e.target.name() === "point-0") { + e.target.scale({ x: 2, y: 2 }); + setCursor("crosshair"); + } else { + setCursor("move"); + } }; - const handleGroupMouseOut = ( - e: Konva.KonvaEventObject, + const handleMouseOutPoint = ( + e: KonvaEventObject, ) => { - if (!e.target || !isFinished) return; - e.target.getStage()!.container().style.cursor = "default"; + if (!e.target) return; + + if (isFinished) { + setCursor("default"); + } else { + setCursor("crosshair"); + } + + if (e.target.name() === "point-0") { + e.target.scale({ x: 1, y: 1 }); + } }; const handleGroupDragStart = () => { @@ -75,13 +82,13 @@ export default function PolygonDrawer({ }; const groupDragBound = (pos: Vector2d) => { - if (!stage) { + if (!stageRef.current) { return pos; } let { x, y } = pos; - const sw = stage.width(); - const sh = stage.height(); + const sw = stageRef.current.width(); + const sh = stageRef.current.height(); if (minMaxY[0] + y < 0) y = -1 * minMaxY[0]; if (minMaxX[0] + x < 0) x = -1 * minMaxX[0]; @@ -98,6 +105,14 @@ export default function PolygonDrawer({ [color], ); + useEffect(() => { + if (!stageRef.current) { + return; + } + + stageRef.current.container().style.cursor = cursor; + }, [stageRef, cursor]); + return ( + isFinished ? setCursor("move") : setCursor("crosshair") + } + onMouseOut={() => + isFinished ? setCursor("default") : setCursor("crosshair") + } /> + {isFinished && isActive && ( + setCursor("crosshair")} + onMouseOut={() => + isFinished ? setCursor("default") : setCursor("crosshair") + } + /> + )} {points.map((point, index) => { if (!isActive) { return; } const x = point[0]; const y = point[1]; - const startPointAttr = - index === 0 - ? { - hitStrokeWidth: 12, - onMouseOver: handleMouseOverStartPoint, - onMouseOut: handleMouseOutStartPoint, - } - : null; - const otherPointsAttr = - index !== 0 - ? { - onMouseOver: handleMouseOverAnyPoint, - onMouseOut: handleMouseOutAnyPoint, - } - : null; return ( { - if (stage) { + if (stageRef.current) { return dragBoundFunc( - stage.width(), - stage.height(), + stageRef.current.width(), + stageRef.current.height(), vertexRadius, pos, ); @@ -162,8 +184,6 @@ export default function PolygonDrawer({ return pos; } }} - {...startPointAttr} - {...otherPointsAttr} /> ); })} diff --git a/web/src/components/settings/PolygonEditControls.tsx b/web/src/components/settings/PolygonEditControls.tsx index 5aa4323b2..37a4cc695 100644 --- a/web/src/components/settings/PolygonEditControls.tsx +++ b/web/src/components/settings/PolygonEditControls.tsx @@ -41,7 +41,7 @@ export default function PolygonEditControls({ ...activePolygon.pointsOrder.slice(0, lastPointOrderIndex), ...activePolygon.pointsOrder.slice(lastPointOrderIndex + 1), ], - isFinished: false, + isFinished: activePolygon.isFinished && activePolygon.points.length > 3, }; setPolygons(updatedPolygons); diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index 9d8972341..bfd73725d 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -202,7 +202,7 @@ export default function PolygonItem({ return ( <> - +
{ + document.title = "Edit Zone - Frigate"; + }, []); + if (!polygon) { return; } return ( <> - + {polygon.name.length ? "Edit" : "New"} Zone @@ -551,14 +555,6 @@ export function ZoneObjectSelector({ const labels = new Set(); - // Object.values(config.cameras).forEach((camera) => { - // camera.objects.track.forEach((label) => { - // if (!ATTRIBUTE_LABELS.includes(label)) { - // labels.add(label); - // } - // }); - // }); - cameraConfig.objects.track.forEach((label) => { if (!ATTRIBUTE_LABELS.includes(label)) { labels.add(label); diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 438842e23..aa39e8abd 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -38,12 +38,8 @@ export function MotionSegment({ dense, }: MotionSegmentProps) { const severityType = "all"; - const { - getSeverity, - getReviewed, - displaySeverityType, - shouldShowRoundedCorners, - } = useEventSegmentUtils(segmentDuration, events, severityType); + const { getSeverity, getReviewed, displaySeverityType } = + useEventSegmentUtils(segmentDuration, events, severityType); const { interpolateMotionAudioData } = useMotionSegmentUtils( segmentDuration, @@ -68,11 +64,6 @@ export function MotionSegment({ [getReviewed, segmentTime], ); - const { roundTopSecondary, roundBottomSecondary } = useMemo( - () => shouldShowRoundedCorners(segmentTime), - [shouldShowRoundedCorners, segmentTime], - ); - const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]); const segmentKey = useMemo(() => segmentTime, [segmentTime]); @@ -152,16 +143,16 @@ export function MotionSegment({ const animationClassesFirstHalf = `motion-segment ${firstHalfSegmentWidth > 0 ? "hidden" : ""} zoom-in-[0.2] ${firstHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"}`; - const severityColors: { [key: number]: string } = { + const severityColorsBg: { [key: number]: string } = { 1: reviewed - ? "from-severity_significant_motion-dimmed/50 to-severity_significant_motion/50" - : "from-severity_significant_motion-dimmed to-severity_significant_motion", + ? "from-severity_significant_motion-dimmed/10 to-severity_significant_motion/10" + : "from-severity_significant_motion-dimmed/20 to-severity_significant_motion/20", 2: reviewed - ? "from-severity_detection-dimmed/50 to-severity_detection/50" - : "from-severity_detection-dimmed to-severity_detection", + ? "from-severity_detection-dimmed/10 to-severity_detection/10" + : "from-severity_detection-dimmed/20 to-severity_detection/20", 3: reviewed - ? "from-severity_alert-dimmed/50 to-severity_alert/50" - : "from-severity_alert-dimmed to-severity_alert", + ? "from-severity_alert-dimmed/10 to-severity_alert/10" + : "from-severity_alert-dimmed/20 to-severity_alert/20", }; const segmentClick = useCallback(() => { @@ -179,7 +170,7 @@ export function MotionSegment({
0 || secondHalfSegmentWidth > 0 ? "has-data" : ""} ${segmentClasses}`} + className={`segment ${firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0 ? "has-data" : ""} ${segmentClasses} bg-gradient-to-r ${severityColorsBg[severity[0]]}`} onClick={segmentClick} onTouchEnd={(event) => handleTouchStart(event, segmentClick)} > @@ -219,7 +210,7 @@ export function MotionSegment({
- - {!motionOnly && - severity.map((severityValue: number, index: number) => { - if (severityValue > 0) { - return ( - -
-
-
-
- ); - } else { - return null; - } - })}
)} diff --git a/web/src/components/timeline/SummaryTimeline.tsx b/web/src/components/timeline/SummaryTimeline.tsx index 40684543c..db1200546 100644 --- a/web/src/components/timeline/SummaryTimeline.tsx +++ b/web/src/components/timeline/SummaryTimeline.tsx @@ -9,7 +9,6 @@ import { import { SummarySegment } from "./SummarySegment"; import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { ReviewSegment, ReviewSeverity } from "@/types/review"; -import { isMobile } from "react-device-detect"; export type SummaryTimelineProps = { reviewTimelineRef: RefObject; @@ -188,7 +187,7 @@ export function SummaryTimeline({ e.stopPropagation(); let clientY; - if (isMobile && e.nativeEvent instanceof TouchEvent) { + if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) { clientY = e.nativeEvent.touches[0].clientY; } else if (e.nativeEvent instanceof MouseEvent) { clientY = e.nativeEvent.clientY; @@ -239,7 +238,7 @@ export function SummaryTimeline({ setIsDragging(true); let clientY; - if (isMobile && e.nativeEvent instanceof TouchEvent) { + if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) { clientY = e.nativeEvent.touches[0].clientY; } else if (e.nativeEvent instanceof MouseEvent) { clientY = e.nativeEvent.clientY; @@ -277,7 +276,7 @@ export function SummaryTimeline({ } e.stopPropagation(); let clientY; - if (isMobile && e instanceof TouchEvent) { + if ("TouchEvent" in window && e instanceof TouchEvent) { clientY = e.touches[0].clientY; } else if (e instanceof MouseEvent) { clientY = e.clientY; diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx index bbba7e0eb..bba83f977 100644 --- a/web/src/components/ui/popover.tsx +++ b/web/src/components/ui/popover.tsx @@ -1,29 +1,36 @@ -import * as React from "react" -import * as PopoverPrimitive from "@radix-ui/react-popover" +import * as React from "react"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Popover = PopoverPrimitive.Root +const Popover = PopoverPrimitive.Root; -const PopoverTrigger = PopoverPrimitive.Trigger +const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - - - -)) -PopoverContent.displayName = PopoverPrimitive.Content.displayName + React.ComponentPropsWithoutRef & { + container?: HTMLElement | null; + } +>( + ( + { className, container, align = "center", sideOffset = 4, ...props }, + ref, + ) => ( + + + + ), +); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent } +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/web/src/components/ui/sonner.tsx b/web/src/components/ui/sonner.tsx index 15690ddc7..21da14e4e 100644 --- a/web/src/components/ui/sonner.tsx +++ b/web/src/components/ui/sonner.tsx @@ -18,6 +18,8 @@ const Toaster = ({ ...props }: ToasterProps) => { actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary", cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + closeButton: + "group-[.toast]:bg-secondary border-primary border-[1px]", success: "group toast group-[.toaster]:bg-success group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", error: diff --git a/web/src/context/statusbar-provider.tsx b/web/src/context/statusbar-provider.tsx index 6d17fa4de..60954f7be 100644 --- a/web/src/context/statusbar-provider.tsx +++ b/web/src/context/statusbar-provider.tsx @@ -10,6 +10,7 @@ export type StatusMessage = { id: string; text: string; color?: string; + link?: string; }; export type StatusMessagesState = { @@ -27,6 +28,7 @@ type StatusBarMessagesContextValue = { message: string, color?: string, messageId?: string, + link?: string, ) => string; removeMessage: (key: string, messageId: string) => void; clearMessages: (key: string) => void; @@ -43,14 +45,20 @@ export function StatusBarMessagesProvider({ const messages = useMemo(() => messagesState, [messagesState]); const addMessage = useCallback( - (key: string, message: string, color?: string, messageId?: string) => { + ( + key: string, + message: string, + color?: string, + messageId?: string, + link?: string, + ) => { const id = messageId || Date.now().toString(); const msgColor = color || "text-danger"; setMessagesState((prevMessages) => ({ ...prevMessages, [key]: [ ...(prevMessages[key] || []), - { id, text: message, color: msgColor }, + { id, text: message, color: msgColor, link }, ], })); return id; diff --git a/web/src/hooks/use-camera-activity.ts b/web/src/hooks/use-camera-activity.ts index 99b8702b5..074c2be52 100644 --- a/web/src/hooks/use-camera-activity.ts +++ b/web/src/hooks/use-camera-activity.ts @@ -1,62 +1,123 @@ -import { useFrigateEvents, useMotionActivity } from "@/api/ws"; -import { CameraConfig } from "@/types/frigateConfig"; +import { + useFrigateEvents, + useInitialCameraState, + useMotionActivity, +} from "@/api/ws"; +import { ATTRIBUTE_LABELS, CameraConfig } from "@/types/frigateConfig"; import { MotionData, ReviewSegment } from "@/types/review"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTimelineUtils } from "./use-timeline-utils"; +import { ObjectType } from "@/types/ws"; +import useDeepMemo from "./use-deep-memo"; +import { isEqual } from "lodash"; type useCameraActivityReturn = { activeTracking: boolean; activeMotion: boolean; + objects: ObjectType[]; }; export function useCameraActivity( camera: CameraConfig, ): useCameraActivityReturn { - const [activeObjects, setActiveObjects] = useState([]); + const [objects, setObjects] = useState([]); + + // init camera activity + + const { payload: initialCameraState } = useInitialCameraState(camera.name); + + const updatedCameraState = useDeepMemo(initialCameraState); + + useEffect(() => { + if (updatedCameraState) { + setObjects(updatedCameraState.objects); + } + }, [updatedCameraState, camera]); + + // handle camera activity + const hasActiveObjects = useMemo( - () => activeObjects.length > 0, - [activeObjects], + () => objects.filter((obj) => !obj.stationary).length > 0, + [objects], ); const { payload: detectingMotion } = useMotionActivity(camera.name); const { payload: event } = useFrigateEvents(); + const updatedEvent = useDeepMemo(event); + + const handleSetObjects = useCallback( + (newObjects: ObjectType[]) => { + if (!isEqual(objects, newObjects)) { + setObjects(newObjects); + } + }, + [objects], + ); useEffect(() => { - if (!event) { + if (!updatedEvent) { return; } - if (event.after.camera != camera.name) { + if (updatedEvent.after.camera !== camera.name) { return; } - const eventIndex = activeObjects.indexOf(event.after.id); + const updatedEventIndex = objects.findIndex( + (obj) => obj.id === updatedEvent.after.id, + ); - if (event.type == "end") { - if (eventIndex != -1) { - const newActiveObjects = [...activeObjects]; - newActiveObjects.splice(eventIndex, 1); - setActiveObjects(newActiveObjects); + let newObjects: ObjectType[] = [...objects]; + + if (updatedEvent.type === "end") { + if (updatedEventIndex !== -1) { + newObjects.splice(updatedEventIndex, 1); } } else { - if (eventIndex == -1) { - // add unknown event to list if not stationary - if (!event.after.stationary) { - const newActiveObjects = [...activeObjects, event.after.id]; - setActiveObjects(newActiveObjects); + if (updatedEventIndex === -1) { + // add unknown updatedEvent to list if not stationary + if (!updatedEvent.after.stationary) { + const newActiveObject: ObjectType = { + id: updatedEvent.after.id, + label: updatedEvent.after.label, + stationary: updatedEvent.after.stationary, + area: updatedEvent.after.area, + ratio: updatedEvent.after.ratio, + score: updatedEvent.after.score, + sub_label: updatedEvent.after.sub_label?.[0] ?? "", + }; + newObjects = [...objects, newActiveObject]; } } else { - // remove known event from list if it has become stationary - if (event.after.stationary) { - activeObjects.splice(eventIndex, 1); + const newObjects = [...objects]; + + let label = updatedEvent.after.label; + + if (updatedEvent.after.sub_label) { + const sub_label = updatedEvent.after.sub_label[0]; + + if (ATTRIBUTE_LABELS.includes(sub_label)) { + label = sub_label; + } else { + label = `${label}-verified`; + } } + + newObjects[updatedEventIndex].label = label; + newObjects[updatedEventIndex].stationary = + updatedEvent.after.stationary; } } - }, [camera, event, activeObjects]); + + handleSetObjects(newObjects); + }, [camera, updatedEvent, objects, handleSetObjects]); return { activeTracking: hasActiveObjects, - activeMotion: detectingMotion == "ON", + activeMotion: detectingMotion + ? detectingMotion === "ON" + : initialCameraState?.motion === true, + objects, }; } diff --git a/web/src/hooks/use-camera-previews.ts b/web/src/hooks/use-camera-previews.ts new file mode 100644 index 000000000..db2ff33f8 --- /dev/null +++ b/web/src/hooks/use-camera-previews.ts @@ -0,0 +1,57 @@ +import { Preview } from "@/types/preview"; +import { TimeRange } from "@/types/timeline"; +import { useEffect, useState } from "react"; +import useSWR from "swr"; + +type OptionalCameraPreviewProps = { + camera?: string; + autoRefresh?: boolean; + fetchPreviews?: boolean; +}; + +export function useCameraPreviews( + initialTimeRange: TimeRange, + { + camera = "all", + autoRefresh = true, + fetchPreviews = true, + }: OptionalCameraPreviewProps, +) { + const [timeRange, setTimeRange] = useState(initialTimeRange); + + useEffect(() => { + setTimeRange(initialTimeRange); + }, [initialTimeRange]); + + const { data: allPreviews } = useSWR( + fetchPreviews + ? `preview/${camera}/start/${Math.round(timeRange.after)}/end/${Math.round(timeRange.before)}` + : null, + { revalidateOnFocus: false, revalidateOnReconnect: false }, + ); + + // Set a timeout to update previews on the hour + useEffect(() => { + if (!autoRefresh || !fetchPreviews || !allPreviews) { + return; + } + + const callback = () => { + const nextPreviewStart = new Date( + allPreviews[allPreviews.length - 1].end * 1000, + ); + nextPreviewStart.setHours(nextPreviewStart.getHours() + 1); + + if (Date.now() > nextPreviewStart.getTime()) { + setTimeRange({ after: timeRange.after, before: Date.now() / 1000 }); + } + }; + document.addEventListener("focusin", callback); + + return () => { + document.removeEventListener("focusin", callback); + }; + }, [allPreviews, autoRefresh, fetchPreviews, timeRange]); + + return allPreviews; +} diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 75f733aeb..8cc61d451 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -1,5 +1,4 @@ import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; -import { isMobile } from "react-device-detect"; import scrollIntoView from "scroll-into-view-if-needed"; import { useTimelineUtils } from "./use-timeline-utils"; @@ -88,7 +87,7 @@ function useDraggableElement({ const getClientYPosition = useCallback( (e: MouseEvent | TouchEvent) => { let clientY; - if (isMobile && e instanceof TouchEvent) { + if ("TouchEvent" in window && e instanceof TouchEvent) { clientY = e.touches[0].clientY; } else if (e instanceof MouseEvent) { clientY = e.clientY; @@ -114,7 +113,7 @@ function useDraggableElement({ setIsDragging(true); let clientY; - if (isMobile && e.nativeEvent instanceof TouchEvent) { + if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) { clientY = e.nativeEvent.touches[0].clientY; } else if (e.nativeEvent instanceof MouseEvent) { clientY = e.nativeEvent.clientY; diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index 656b61bda..f39717288 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -8,7 +8,8 @@ export function useOverlayState( ): [S | undefined, (value: S, replace?: boolean) => void] { const location = useLocation(); const navigate = useNavigate(); - const currentLocationState = location.state; + + const currentLocationState = useMemo(() => location.state, [location]); const setOverlayStateValue = useCallback( (value: S, replace: boolean = false) => { @@ -18,7 +19,7 @@ export function useOverlayState( }, // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - [key, navigate], + [key, currentLocationState, navigate], ); const overlayStateValue = useMemo( @@ -32,14 +33,16 @@ export function useOverlayState( export function usePersistedOverlayState( key: string, defaultValue: S | undefined = undefined, -): [S | undefined, (value: S | undefined, replace?: boolean) => void] { - const [persistedValue, setPersistedValue] = usePersistence( - key, - defaultValue, - ); +): [ + S | undefined, + (value: S | undefined, replace?: boolean) => void, + () => void, +] { + const [persistedValue, setPersistedValue, , deletePersistedValue] = + usePersistence(key, defaultValue); const location = useLocation(); const navigate = useNavigate(); - const currentLocationState = location.state; + const currentLocationState = useMemo(() => location.state, [location]); const setOverlayStateValue = useCallback( (value: S | undefined, replace: boolean = false) => { @@ -50,7 +53,7 @@ export function usePersistedOverlayState( }, // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - [key, navigate], + [key, currentLocationState, navigate], ); const overlayStateValue = useMemo( @@ -61,6 +64,7 @@ export function usePersistedOverlayState( return [ overlayStateValue ?? persistedValue ?? defaultValue, setOverlayStateValue, + deletePersistedValue, ]; } diff --git a/web/src/hooks/use-persistence.ts b/web/src/hooks/use-persistence.ts index 1b2f2a4d4..8762c1970 100644 --- a/web/src/hooks/use-persistence.ts +++ b/web/src/hooks/use-persistence.ts @@ -1,10 +1,11 @@ import { useEffect, useState, useCallback } from "react"; -import { get as getData, set as setData } from "idb-keyval"; +import { get as getData, set as setData, del as delData } from "idb-keyval"; type usePersistenceReturn = [ value: S | undefined, setValue: (value: S | undefined) => void, loaded: boolean, + deleteValue: () => void, ]; export function usePersistence( @@ -26,6 +27,11 @@ export function usePersistence( [key], ); + const deleteValue = useCallback(async () => { + await delData(key); + setInternalValue(defaultValue); + }, [key, defaultValue]); + useEffect(() => { setLoaded(false); setInternalValue(defaultValue); @@ -41,5 +47,5 @@ export function usePersistence( load(); }, [key, defaultValue, setValue]); - return [value, setValue, loaded]; + return [value, setValue, loaded, deleteValue]; } diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 1cb30dbb7..bafc9e538 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -34,11 +34,13 @@ export default function useStats(stats: FrigateStats | undefined) { problems.push({ text: `${capitalizeFirstLetter(key)} is very slow (${det["inference_speed"]} ms)`, color: "text-danger", + relevantLink: "/system#general", }); } else if (det["inference_speed"] > InferenceThreshold.warning) { problems.push({ text: `${capitalizeFirstLetter(key)} is slow (${det["inference_speed"]} ms)`, color: "text-orange-400", + relevantLink: "/system#general", }); } }); @@ -53,6 +55,7 @@ export default function useStats(stats: FrigateStats | undefined) { problems.push({ text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} is offline`, color: "text-danger", + relevantLink: "logs", }); } }); @@ -70,6 +73,7 @@ export default function useStats(stats: FrigateStats | undefined) { problems.push({ text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high FFMPEG CPU usage (${ffmpegAvg}%)`, color: "text-danger", + relevantLink: "/system#cameras", }); } @@ -77,6 +81,7 @@ export default function useStats(stats: FrigateStats | undefined) { problems.push({ text: `${capitalizeFirstLetter(name.replaceAll("_", " "))} has high detect CPU usage (${detectAvg}%)`, color: "text-danger", + relevantLink: "/system#cameras", }); } }); diff --git a/web/src/pages/ConfigEditor.tsx b/web/src/pages/ConfigEditor.tsx index 126d6c66a..7c30b5447 100644 --- a/web/src/pages/ConfigEditor.tsx +++ b/web/src/pages/ConfigEditor.tsx @@ -158,7 +158,7 @@ function ConfigEditor() { )}
- +
); } diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 0cc3cd6b5..bf325f064 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,9 +1,9 @@ import ActivityIndicator from "@/components/indicators/activity-indicator"; import useApiFilter from "@/hooks/use-api-filter"; +import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useTimezone } from "@/hooks/use-date-utils"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { FrigateConfig } from "@/types/frigateConfig"; -import { Preview } from "@/types/preview"; import { RecordingStartingPoint } from "@/types/record"; import { ReviewFilter, @@ -161,7 +161,6 @@ export default function Events() { }, [updateSummary]); // preview videos - const [previewKey, setPreviewKey] = useState(0); const previewTimes = useMemo(() => { if (!reviews || reviews.length == 0) { return undefined; @@ -170,50 +169,22 @@ export default function Events() { const startDate = new Date(); startDate.setMinutes(0, 0, 0); - let endDate; - if (previewKey == 0) { - endDate = new Date(reviews.at(-1)?.end_time || 0); - endDate.setHours(0, 0, 0, 0); - } else { - endDate = new Date(); - endDate.setMilliseconds(0); - } + const endDate = new Date(reviews.at(-1)?.end_time || 0); + endDate.setHours(0, 0, 0, 0); return { - start: startDate.getTime() / 1000, - end: endDate.getTime() / 1000, + after: startDate.getTime() / 1000, + before: endDate.getTime() / 1000, }; - }, [reviews, previewKey]); - const { data: allPreviews } = useSWR( - previewTimes - ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` - : null, - { revalidateOnFocus: false, revalidateOnReconnect: false }, + }, [reviews]); + + const allPreviews = useCameraPreviews( + previewTimes ?? { after: 0, before: 0 }, + { + fetchPreviews: previewTimes != undefined, + }, ); - // Set a timeout to update previews on the hour - useEffect(() => { - if (!allPreviews || allPreviews.length == 0) { - return; - } - - const callback = () => { - const nextPreviewStart = new Date( - allPreviews[allPreviews.length - 1].end * 1000, - ); - nextPreviewStart.setHours(nextPreviewStart.getHours() + 1); - - if (Date.now() > nextPreviewStart.getTime()) { - setPreviewKey(10 * Math.random()); - } - }; - document.addEventListener("focusin", callback); - - return () => { - document.removeEventListener("focusin", callback); - }; - }, [allPreviews]); - // review status const markAllItemsAsReviewed = useCallback( diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 4be6b2c16..bae56b6bb 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -38,7 +38,13 @@ function Live() { // settings const includesBirdseye = useMemo(() => { - if (config && cameraGroup && cameraGroup != "default") { + if ( + config && + Object.keys(config.camera_groups).length && + cameraGroup && + config.camera_groups[cameraGroup] && + cameraGroup != "default" + ) { return config.camera_groups[cameraGroup].cameras.includes("birdseye"); } else { return false; @@ -50,7 +56,12 @@ function Live() { return []; } - if (cameraGroup && cameraGroup != "default") { + if ( + Object.keys(config.camera_groups).length && + cameraGroup && + config.camera_groups[cameraGroup] && + cameraGroup != "default" + ) { const group = config.camera_groups[cameraGroup]; return Object.values(config.cameras) .filter((conf) => conf.enabled && group.cameras.includes(conf.name)) @@ -78,6 +89,7 @@ function Live() { return ( diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx index c166968ab..d1a8a7435 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -12,6 +12,7 @@ import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; import { isDesktop } from "react-device-detect"; import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { cn } from "@/lib/utils"; const logTypes = ["frigate", "go2rtc", "nginx"] as const; type LogType = (typeof logTypes)[number]; @@ -332,7 +333,7 @@ function Logs() { return (
- +
@@ -472,7 +473,11 @@ function LogLineData({ return (
diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 9d22e534d..278fecac9 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -37,9 +37,9 @@ import scrollIntoView from "scroll-into-view-if-needed"; export default function Settings() { const settingsViews = [ "general", - "objects", "masks / zones", "motion tuner", + "debug", ] as const; type SettingsType = (typeof settingsViews)[number]; @@ -100,6 +100,10 @@ export default function Settings() { } }, [tabsRef, pageToggle]); + useEffect(() => { + document.title = "Settings - Frigate"; + }, []); + return (
@@ -131,7 +135,7 @@ export default function Settings() {
- {(page == "objects" || + {(page == "debug" || page == "masks / zones" || page == "motion tuner") && (
@@ -151,9 +155,7 @@ export default function Settings() {
{page == "general" && } - {page == "objects" && ( - - )} + {page == "debug" && } {page == "masks / zones" && ( )} -
+
{allCameras.map((item) => ( ("config"); @@ -56,21 +68,93 @@ export default function SubmitPlus() { // data - const { data: events, mutate: refresh } = useSWR([ - "events", - { - limit: 100, - in_progress: 0, - is_submitted: 0, - cameras: selectedCameras ? selectedCameras.join(",") : null, - labels: selectedLabels ? selectedLabels.join(",") : null, - min_score: scoreRange ? scoreRange[0] : null, - max_score: scoreRange ? scoreRange[1] : null, - sort: sort ? sort : null, + const eventFetcher = useCallback((key: string) => { + const [path, params] = Array.isArray(key) ? key : [key, undefined]; + return axios.get(path, { params }).then((res) => res.data); + }, []); + + const getKey = useCallback( + (index: number, prevData: Event[]) => { + if (index > 0) { + const lastDate = prevData[prevData.length - 1].start_time; + return [ + "events", + { + limit: API_LIMIT, + in_progress: 0, + is_submitted: 0, + cameras: selectedCameras ? selectedCameras.join(",") : null, + labels: selectedLabels ? selectedLabels.join(",") : null, + min_score: scoreRange ? scoreRange[0] : null, + max_score: scoreRange ? scoreRange[1] : null, + sort: sort ? sort : null, + before: lastDate, + }, + ]; + } + + return [ + "events", + { + limit: 100, + in_progress: 0, + is_submitted: 0, + cameras: selectedCameras ? selectedCameras.join(",") : null, + labels: selectedLabels ? selectedLabels.join(",") : null, + min_score: scoreRange ? scoreRange[0] : null, + max_score: scoreRange ? scoreRange[1] : null, + sort: sort ? sort : null, + }, + ]; }, - ]); + [scoreRange, selectedCameras, selectedLabels, sort], + ); + + const { + data: eventPages, + mutate: refresh, + size, + setSize, + isValidating, + } = useSWRInfinite(getKey, eventFetcher, { + revalidateOnFocus: false, + }); + + const events = useMemo( + () => (eventPages ? eventPages.flat() : []), + [eventPages], + ); + const [upload, setUpload] = useState(); + // paging + + const isDone = useMemo( + () => (eventPages?.at(-1)?.length ?? 0) < API_LIMIT, + [eventPages], + ); + + const pagingObserver = useRef(); + const lastEventRef = useCallback( + (node: HTMLElement | null) => { + if (isValidating) return; + if (pagingObserver.current) pagingObserver.current.disconnect(); + try { + pagingObserver.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isDone) { + setSize(size + 1); + } + }); + if (node) pagingObserver.current.observe(node); + } catch (e) { + // no op + } + }, + [isValidating, isDone, size, setSize], + ); + + // layout + const grow = useMemo(() => { if (!config || !upload) { return ""; @@ -102,18 +186,35 @@ export default function SubmitPlus() { }); refresh( - (data: Event[] | undefined) => { + (data: Event[][] | undefined) => { if (!data) { return data; } - const index = data.findIndex((e) => e.id == upload.id); + let pageIndex = -1; + let index = -1; + + data.forEach((page, pIdx) => { + const search = page.findIndex((e) => e.id == upload.id); + + if (search != -1) { + pageIndex = pIdx; + index = search; + } + }); if (index == -1) { return data; } - return [...data.slice(0, index), ...data.slice(index + 1)]; + return [ + ...data.slice(0, pageIndex), + [ + ...data[pageIndex].slice(0, index), + ...data[pageIndex].slice(index + 1), + ], + ...data.slice(pageIndex + 1), + ]; }, { revalidate: false, populateCache: true }, ); @@ -141,7 +242,7 @@ export default function SubmitPlus() { open={upload != undefined} onOpenChange={(open) => (!open ? setUpload(undefined) : null)} > - + Submit To Frigate+ @@ -174,17 +275,47 @@ export default function SubmitPlus() { - {events?.map((event) => { + {events?.map((event, eIdx) => { if (event.data.type != "object") { return; } + const lastRow = eIdx == events.length - 1; + return (
setUpload(event)} > +
+ +
+ +
+ + {[event.label].map((object) => { + return getIconForLabel( + object, + "size-3 text-white", + ); + })} + +
+
+
+ + {[event.label] + .map((text) => capitalizeFirstLetter(text)) + .sort() + .join(", ") + .replaceAll("-verified", "")} + +
+
); })} + + {isValidating && }
diff --git a/web/src/pages/System.tsx b/web/src/pages/System.tsx index 5d296f053..8d30c82ed 100644 --- a/web/src/pages/System.tsx +++ b/web/src/pages/System.tsx @@ -11,6 +11,8 @@ import { FaVideo } from "react-icons/fa"; import Logo from "@/components/Logo"; import useOptimisticState from "@/hooks/use-optimistic-state"; import CameraMetrics from "@/views/system/CameraMetrics"; +import { useHashState } from "@/hooks/use-overlay-state"; +import { capitalizeFirstLetter } from "@/utils/stringUtil"; const metrics = ["general", "storage", "cameras"] as const; type SystemMetric = (typeof metrics)[number]; @@ -18,12 +20,18 @@ type SystemMetric = (typeof metrics)[number]; function System() { // stats page - const [page, setPage] = useState("general"); - const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); + const [page, setPage] = useHashState(); + const [pageToggle, setPageToggle] = useOptimisticState( + page ?? "general", + setPage, + 100, + ); const [lastUpdated, setLastUpdated] = useState(Date.now() / 1000); useEffect(() => { - document.title = `${pageToggle[0].toUpperCase()}${pageToggle.substring(1)} Stats - Frigate`; + if (pageToggle) { + document.title = `${capitalizeFirstLetter(pageToggle)} Stats - Frigate`; + } }, [pageToggle]); // stats collection diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 3cad4696b..67602a858 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -28,6 +28,7 @@ import { Label } from "@/components/ui/label"; import { useNavigate } from "react-router-dom"; import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { isMobile } from "react-device-detect"; +import IconPicker, { IconElement } from "@/components/icons/IconPicker"; // Color data const colors = [ @@ -207,6 +208,8 @@ function UIPlayground() { const [isEventsReviewTimeline, setIsEventsReviewTimeline] = useState(true); const birdseyeConfig = config?.birdseye; + const [selectedIcon, setSelectedIcon] = useState(); + return ( <>
@@ -214,6 +217,15 @@ function UIPlayground() {
UI Playground + + + {selectedIcon?.name && ( +

Selected icon name: {selectedIcon.name}

+ )} + Scrubber diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index b5c89f5db..b24841151 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -1,3 +1,4 @@ +import { IconName } from "@/components/icons/IconPicker"; import { LivePlayerMode } from "./live"; export interface UiConfig { @@ -222,11 +223,9 @@ export interface CameraConfig { }; } -export const GROUP_ICONS = ["car", "cat", "dog", "leaf"] as const; - export type CameraGroupConfig = { cameras: string[]; - icon: (typeof GROUP_ICONS)[number]; + icon: IconName; order: number; }; @@ -324,6 +323,7 @@ export interface FrigateConfig { model_type: string; path: string | null; width: number; + colormap: { [key: string]: [number, number, number] }; }; motion: Record | null; diff --git a/web/src/types/live.ts b/web/src/types/live.ts index 7e413894f..34d6b17f6 100644 --- a/web/src/types/live.ts +++ b/web/src/types/live.ts @@ -1 +1,5 @@ export type LivePlayerMode = "webrtc" | "mse" | "jsmpeg" | "debug"; +export type VideoResolutionType = { + width: number; + height: number; +}; diff --git a/web/src/types/preview.ts b/web/src/types/preview.ts index e9bd12185..a9f958f7f 100644 --- a/web/src/types/preview.ts +++ b/web/src/types/preview.ts @@ -1,3 +1,5 @@ +import { REVIEW_PADDING } from "./review"; + export type Preview = { camera: string; src: string; @@ -5,3 +7,6 @@ export type Preview = { start: number; end: number; }; + +export const PREVIEW_FPS = 8; +export const PREVIEW_PADDING = REVIEW_PADDING * PREVIEW_FPS; diff --git a/web/src/types/record.ts b/web/src/types/record.ts index 1efa8565f..d3fcfce94 100644 --- a/web/src/types/record.ts +++ b/web/src/types/record.ts @@ -38,3 +38,6 @@ export type RecordingStartingPoint = { startTime: number; severity: ReviewSeverity; }; + +export const ASPECT_VERTICAL_LAYOUT = 1.5; +export const ASPECT_WIDE_LAYOUT = 2; diff --git a/web/src/types/review.ts b/web/src/types/review.ts index b8e5254d9..989499175 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -26,6 +26,7 @@ export type ReviewFilter = { before?: number; after?: number; showReviewed?: 0 | 1; + showAll?: boolean; }; type ReviewSummaryDay = { @@ -48,3 +49,5 @@ export type MotionData = { audio?: number; camera: string; }; + +export const REVIEW_PADDING = 2; diff --git a/web/src/types/stats.ts b/web/src/types/stats.ts index 59fb6f69d..2cf277f3d 100644 --- a/web/src/types/stats.ts +++ b/web/src/types/stats.ts @@ -62,6 +62,7 @@ export type StorageStats = { export type PotentialProblem = { text: string; color: string; + relevantLink?: string; }; export type Vainfo = { diff --git a/web/src/types/ws.ts b/web/src/types/ws.ts index 177d3600a..0fae44b07 100644 --- a/web/src/types/ws.ts +++ b/web/src/types/ws.ts @@ -41,4 +41,19 @@ export interface FrigateEvent { after: FrigateObjectState; } +export type ObjectType = { + id: string; + label: string; + stationary: boolean; + area: number; + ratio: number; + score: number; + sub_label: string; +}; + +export interface FrigateCameraState { + motion: boolean; + objects: ObjectType[]; +} + export type ToggleableSetting = "ON" | "OFF"; diff --git a/web/src/utils/iconUtil.tsx b/web/src/utils/iconUtil.tsx index bc9f74a8b..3e8b8cca0 100644 --- a/web/src/utils/iconUtil.tsx +++ b/web/src/utils/iconUtil.tsx @@ -1,37 +1,24 @@ +import { IconName } from "@/components/icons/IconPicker"; import { BsPersonWalking } from "react-icons/bs"; import { FaAmazon, + FaBicycle, + FaBus, FaCarSide, FaCat, FaCheckCircle, - FaCircle, FaDog, FaFedex, FaFire, - FaLeaf, FaUps, } from "react-icons/fa"; +import { GiHummingbird } from "react-icons/gi"; import { LuBox, LuLassoSelect } from "react-icons/lu"; +import * as LuIcons from "react-icons/lu"; import { MdRecordVoiceOver } from "react-icons/md"; -export function getIconTypeForGroup(icon: string) { - switch (icon) { - case "car": - return FaCarSide; - case "cat": - return FaCat; - case "dog": - return FaDog; - case "leaf": - return FaLeaf; - default: - return FaCircle; - } -} - -export function getIconForGroup(icon: string, className: string = "size-4") { - const GroupIcon = getIconTypeForGroup(icon); - return ; +export function isValidIconName(value: string): value is IconName { + return Object.keys(LuIcons).includes(value as IconName); } export function getIconForLabel(label: string, className?: string) { @@ -40,10 +27,18 @@ export function getIconForLabel(label: string, className?: string) { } switch (label) { + case "bicycle": + return ; + case "bird": + return ; + case "bus": + return ; case "car": + case "vehicle": return ; case "cat": return ; + case "animal": case "bark": case "dog": return ; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index be6e2d8fe..90ca54531 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -12,6 +12,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { MotionData, + REVIEW_PADDING, ReviewFilter, ReviewSegment, ReviewSeverity, @@ -44,6 +45,8 @@ import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity"; import useOptimisticState from "@/hooks/use-optimistic-state"; import { Skeleton } from "@/components/ui/skeleton"; import scrollIntoView from "scroll-into-view-if-needed"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; type EventViewProps = { reviews?: ReviewSegment[]; @@ -175,7 +178,7 @@ export default function EventView({ } else { onOpenRecording({ camera: review.camera, - startTime: review.start_time, + startTime: review.start_time - REVIEW_PADDING, severity: review.severity, }); @@ -193,10 +196,31 @@ export default function EventView({ return; } - axios.post( - `export/${review.camera}/start/${review.start_time}/end/${review.end_time}`, - { playback: "realtime" }, - ); + axios + .post( + `export/${review.camera}/start/${review.start_time}/end/${review.end_time}`, + { playback: "realtime" }, + ) + .then((response) => { + if (response.status == 200) { + toast.success( + "Successfully started export. View the file in the /exports folder.", + { position: "top-center" }, + ); + } + }) + .catch((error) => { + if (error.response?.data?.message) { + toast.error( + `Failed to start export: ${error.response.data.message}`, + { position: "top-center" }, + ); + } else { + toast.error(`Failed to start export: ${error.message}`, { + position: "top-center", + }); + } + }); }, [reviewItems], ); @@ -214,6 +238,7 @@ export default function EventView({ return (
+
{isMobile && ( @@ -269,6 +294,7 @@ export default function EventView({ ? ["cameras", "date", "motionOnly"] : ["cameras", "reviewed", "date", "general"] } + currentSeverity={severityToggle} reviewSummary={reviewSummary} filter={filter} onUpdateFilter={updateFilter} @@ -369,7 +395,13 @@ function DetectionReview({ return null; } - const current = reviewItems[severity]; + let current; + + if (filter?.showAll) { + current = reviewItems.all; + } else { + current = reviewItems[severity]; + } if (!current || current.length == 0) { return []; @@ -512,7 +544,7 @@ function DetectionReview({ } const element = contentRef.current?.querySelector( - `[data-start="${startTime}"]`, + `[data-start="${startTime + REVIEW_PADDING}"]`, ); if (element) { scrollIntoView(element, { @@ -797,6 +829,11 @@ function MotionReview({ return; } + if (nextTimestamp >= timeRange.before - 4) { + setPlaying(false); + return; + } + const handleTimeout = () => { setCurrentTime(nextTimestamp); timeoutIdRef.current = setTimeout(handleTimeout, 500 / playbackRate); @@ -810,7 +847,7 @@ function MotionReview({ } }; } - }, [playing, playbackRate, nextTimestamp]); + }, [playing, playbackRate, nextTimestamp, setPlaying, timeRange]); const { alignStartDateToTimeline } = useTimelineUtils({ segmentDuration, @@ -954,37 +991,34 @@ function MotionReview({ )}
- {!scrubbing && ( - { - const wasPlaying = playing; + { + const wasPlaying = playing; - if (wasPlaying) { - setPlaying(false); - } + if (wasPlaying) { + setPlaying(false); + } - setCurrentTime(currentTime + diff); + setCurrentTime(currentTime + diff); - if (wasPlaying) { - setTimeout(() => setPlaying(true), 100); - } - }} - onSetPlaybackRate={setPlaybackRate} - show={currentTime < timeRange.before - 4} - /> - )} + if (wasPlaying) { + setTimeout(() => setPlaying(true), 100); + } + }} + onSetPlaybackRate={setPlaybackRate} + /> ); } diff --git a/web/src/views/events/RecordingView.tsx b/web/src/views/events/RecordingView.tsx index 83bcc19ef..5494b6e87 100644 --- a/web/src/views/events/RecordingView.tsx +++ b/web/src/views/events/RecordingView.tsx @@ -15,6 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { Preview } from "@/types/preview"; import { MotionData, + REVIEW_PADDING, ReviewFilter, ReviewSegment, ReviewSummary, @@ -40,6 +41,8 @@ import MobileReviewSettingsDrawer from "@/components/overlay/MobileReviewSetting import Logo from "@/components/Logo"; import { Skeleton } from "@/components/ui/skeleton"; import { FaVideo } from "react-icons/fa"; +import { VideoResolutionType } from "@/types/live"; +import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; const SEGMENT_DURATION = 30; @@ -188,9 +191,18 @@ export function RecordingView({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTime, scrubbing]); + const [fullResolution, setFullResolution] = useState({ + width: 0, + height: 0, + }); + const onSelectCamera = useCallback( (newCam: string) => { setMainCamera(newCam); + setFullResolution({ + width: 0, + height: 0, + }); setPlaybackStart(currentTime); }, [currentTime], @@ -204,6 +216,10 @@ export function RecordingView({ return undefined; } + if (cam == mainCamera && fullResolution.width && fullResolution.height) { + return fullResolution.width / fullResolution.height; + } + const camera = config.cameras[cam]; if (!camera) { @@ -212,7 +228,7 @@ export function RecordingView({ return camera.detect.width / camera.detect.height; }, - [config], + [config, fullResolution, mainCamera], ); const mainCameraAspect = useMemo(() => { @@ -220,9 +236,9 @@ export function RecordingView({ if (!aspectRatio) { return "normal"; - } else if (aspectRatio > 2) { + } else if (aspectRatio > ASPECT_WIDE_LAYOUT) { return "wide"; - } else if (aspectRatio < 16 / 9) { + } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) { return "tall"; } else { return "normal"; @@ -245,7 +261,7 @@ export function RecordingView({ return (
- +
@@ -396,6 +412,7 @@ export function RecordingView({ mainControllerRef.current = controller; }} isScrubbing={scrubbing || exportMode == "timeline"} + setFullResolution={setFullResolution} />
{isDesktop && ( @@ -558,7 +575,7 @@ function Timeline({ currentTime={currentTime} onClick={() => { setScrubbing(true); - setCurrentTime(review.start_time); + setCurrentTime(review.start_time - REVIEW_PADDING); setScrubbing(false); }} /> diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx new file mode 100644 index 000000000..44f7e0b53 --- /dev/null +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -0,0 +1,528 @@ +import { usePersistence } from "@/hooks/use-persistence"; +import { + BirdseyeConfig, + CameraConfig, + FrigateConfig, +} from "@/types/frigateConfig"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Layout, Responsive, WidthProvider } from "react-grid-layout"; +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; +import { LivePlayerMode } from "@/types/live"; +import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useResizeObserver } from "@/hooks/resize-observer"; +import { isEqual } from "lodash"; +import useSWR from "swr"; +import { isDesktop, isMobile, isSafari } from "react-device-detect"; +import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; +import LivePlayer from "@/components/player/LivePlayer"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { IoClose } from "react-icons/io5"; +import { LuMove } from "react-icons/lu"; +import { cn } from "@/lib/utils"; + +type DraggableGridLayoutProps = { + cameras: CameraConfig[]; + cameraGroup: string; + cameraRef: (node: HTMLElement | null) => void; + containerRef: React.RefObject; + includeBirdseye: boolean; + onSelectCamera: (camera: string) => void; + windowVisible: boolean; + visibleCameras: string[]; + isEditMode: boolean; + setIsEditMode: React.Dispatch>; +}; +export default function DraggableGridLayout({ + cameras, + cameraGroup, + containerRef, + cameraRef, + includeBirdseye, + onSelectCamera, + windowVisible, + visibleCameras, + isEditMode, + setIsEditMode, +}: DraggableGridLayoutProps) { + const { data: config } = useSWR("config"); + const birdseyeConfig = useMemo(() => config?.birdseye, [config]); + + const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); + + const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence< + Layout[] + >(`${cameraGroup}-draggable-layout`); + + const [currentCameras, setCurrentCameras] = useState(); + const [currentIncludeBirdseye, setCurrentIncludeBirdseye] = + useState(); + const [currentGridLayout, setCurrentGridLayout] = useState< + Layout[] | undefined + >(); + + const handleLayoutChange = useCallback( + (currentLayout: Layout[]) => { + if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) { + return; + } + // save layout to idb + setGridLayout(currentLayout); + }, + [setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout], + ); + + const generateLayout = useCallback(() => { + if (!isGridLayoutLoaded) { + return; + } + + const cameraNames = + includeBirdseye && birdseyeConfig?.enabled + ? ["birdseye", ...cameras.map((camera) => camera?.name || "")] + : cameras.map((camera) => camera?.name || ""); + + const optionsMap: Layout[] = currentGridLayout + ? currentGridLayout.filter((layout) => cameraNames?.includes(layout.i)) + : []; + + cameraNames.forEach((cameraName, index) => { + const existingLayout = optionsMap.find( + (layout) => layout.i === cameraName, + ); + + // Skip if the camera already exists in the layout + if (existingLayout) { + return; + } + + let aspectRatio; + let col; + + // Handle "birdseye" camera as a special case + if (cameraName === "birdseye") { + aspectRatio = + (birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1); + col = 0; // Set birdseye camera in the first column + } else { + const camera = cameras.find((cam) => cam.name === cameraName); + aspectRatio = + (camera && camera?.detect.width / camera?.detect.height) || 16 / 9; + col = index % 3; // Regular cameras distributed across columns + } + + // Calculate layout options based on aspect ratio + const columnsPerPlayer = 4; + let height; + let width; + + if (aspectRatio < 1) { + // Portrait + height = 2 * columnsPerPlayer; + width = columnsPerPlayer; + } else if (aspectRatio > 2) { + // Wide + height = 1 * columnsPerPlayer; + width = 2 * columnsPerPlayer; + } else { + // Landscape + height = 1 * columnsPerPlayer; + width = columnsPerPlayer; + } + + const options = { + i: cameraName, + x: col * width, + y: 0, // don't set y, grid does automatically + w: width, + h: height, + isDraggable: isEditMode, + isResizable: isEditMode, + }; + + optionsMap.push(options); + }); + + return optionsMap; + }, [ + cameras, + isEditMode, + isGridLayoutLoaded, + currentGridLayout, + includeBirdseye, + birdseyeConfig, + ]); + + useEffect(() => { + if (currentGridLayout) { + const updatedGridLayout = currentGridLayout.map((layout) => ({ + ...layout, + isDraggable: isEditMode, + isResizable: isEditMode, + })); + if (isEditMode) { + setGridLayout(updatedGridLayout); + setCurrentGridLayout(updatedGridLayout); + } else { + setGridLayout(updatedGridLayout); + } + } + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditMode, setGridLayout]); + + useEffect(() => { + if (isGridLayoutLoaded) { + if (gridLayout) { + // set current grid layout from loaded + setCurrentGridLayout(gridLayout); + } else { + // idb is empty, set it with an initial layout + setGridLayout(generateLayout()); + } + } + }, [ + isEditMode, + gridLayout, + currentGridLayout, + setGridLayout, + isGridLayoutLoaded, + generateLayout, + ]); + + useEffect(() => { + if ( + !isEqual(cameras, currentCameras) || + includeBirdseye !== currentIncludeBirdseye + ) { + setCurrentCameras(cameras); + setCurrentIncludeBirdseye(includeBirdseye); + + // set new grid layout in idb + setGridLayout(generateLayout()); + } + }, [ + cameras, + includeBirdseye, + currentCameras, + currentIncludeBirdseye, + setCurrentGridLayout, + generateLayout, + setGridLayout, + isGridLayoutLoaded, + ]); + + const [marginValue, setMarginValue] = useState(16); + + // calculate margin value for browsers that don't have default font size of 16px + useLayoutEffect(() => { + const calculateRemValue = () => { + const htmlElement = document.documentElement; + const fontSize = window.getComputedStyle(htmlElement).fontSize; + setMarginValue(parseFloat(fontSize)); + }; + + calculateRemValue(); + }, []); + + const gridContainerRef = useRef(null); + + const [{ width: containerWidth, height: containerHeight }] = + useResizeObserver(gridContainerRef); + + const hasScrollbar = useMemo(() => { + return ( + containerHeight && + containerRef.current && + containerRef.current.offsetHeight < + (gridContainerRef.current?.scrollHeight ?? 0) + ); + }, [containerRef, gridContainerRef, containerHeight]); + + const cellHeight = useMemo(() => { + const aspectRatio = 16 / 9; + // subtract container margin, 1 camera takes up at least 4 rows + // account for additional margin on bottom of each row + return ( + ((containerWidth ?? window.innerWidth) - 2 * marginValue) / + 12 / + aspectRatio - + marginValue + + marginValue / 4 + ); + }, [containerWidth, marginValue]); + + return ( + <> + {!isGridLayoutLoaded || !currentGridLayout ? ( +
+ {includeBirdseye && birdseyeConfig?.enabled && ( + + )} + {cameras.map((camera) => { + return ( + + ); + })} +
+ ) : ( +
+ + {includeBirdseye && birdseyeConfig?.enabled && ( + onSelectCamera("birdseye")} + > + {isEditMode && } + + )} + {cameras.map((camera) => { + let grow; + const aspectRatio = camera.detect.width / camera.detect.height; + if (aspectRatio > ASPECT_WIDE_LAYOUT) { + grow = `aspect-wide w-full`; + } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) { + grow = `aspect-tall h-full`; + } else { + grow = "aspect-video"; + } + return ( + { + !isEditMode && onSelectCamera(camera.name); + }} + > + {isEditMode && } + + ); + })} + + {isDesktop && ( + + )} +
+ )} + + ); +} + +type DesktopEditLayoutButtonProps = { + isEditMode?: boolean; + setIsEditMode: React.Dispatch>; + hasScrollbar?: boolean | 0 | null; +}; + +function DesktopEditLayoutButton({ + isEditMode, + setIsEditMode, + hasScrollbar, +}: DesktopEditLayoutButtonProps) { + return ( +
+ + + + + + {isEditMode ? "Exit Editing" : "Edit Layout"} + + +
+ ); +} + +function CornerCircles() { + return ( + <> +
+
+
+
+ + ); +} + +type BirdseyeLivePlayerGridItemProps = { + style?: React.CSSProperties; + className?: string; + onMouseDown?: React.MouseEventHandler; + onMouseUp?: React.MouseEventHandler; + onTouchEnd?: React.TouchEventHandler; + children?: React.ReactNode; + birdseyeConfig: BirdseyeConfig; + liveMode: LivePlayerMode; + onClick: () => void; +}; + +const BirdseyeLivePlayerGridItem = React.forwardRef< + HTMLDivElement, + BirdseyeLivePlayerGridItemProps +>( + ( + { + style, + className, + onMouseDown, + onMouseUp, + onTouchEnd, + children, + birdseyeConfig, + liveMode, + onClick, + ...props + }, + ref, + ) => { + return ( +
+ + {children} +
+ ); + }, +); + +type LivePlayerGridItemProps = { + style?: React.CSSProperties; + className: string; + onMouseDown?: React.MouseEventHandler; + onMouseUp?: React.MouseEventHandler; + onTouchEnd?: React.TouchEventHandler; + children?: React.ReactNode; + cameraRef: (node: HTMLElement | null) => void; + windowVisible: boolean; + cameraConfig: CameraConfig; + preferredLiveMode: LivePlayerMode; + onClick: () => void; +}; + +const LivePlayerGridItem = React.forwardRef< + HTMLDivElement, + LivePlayerGridItemProps +>( + ( + { + style, + className, + onMouseDown, + onMouseUp, + onTouchEnd, + children, + cameraRef, + windowVisible, + cameraConfig, + preferredLiveMode, + onClick, + ...props + }, + ref, + ) => { + return ( +
+ + {children} +
+ ); + }, +); diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index eaf2a63bd..20e7df8fa 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -20,6 +20,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { useResizeObserver } from "@/hooks/resize-observer"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { CameraConfig } from "@/types/frigateConfig"; +import { VideoResolutionType } from "@/types/live"; import { CameraPtzInfo } from "@/types/ptz"; import { RecordingStartingPoint } from "@/types/record"; import React, { @@ -97,7 +98,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { let clientX; let clientY; - if (isMobile && e.nativeEvent instanceof TouchEvent) { + if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) { clientX = e.nativeEvent.touches[0].clientX; clientY = e.nativeEvent.touches[0].clientY; } else if (e.nativeEvent instanceof MouseEvent) { @@ -149,14 +150,24 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { const [fullscreen, setFullscreen] = useState(false); const [pip, setPip] = useState(false); + const [fullResolution, setFullResolution] = useState({ + width: 0, + height: 0, + }); + const growClassName = useMemo(() => { - const aspect = camera.detect.width / camera.detect.height; + let aspect; + if (fullResolution.width && fullResolution.height) { + aspect = fullResolution.width / fullResolution.height; + } else { + aspect = camera.detect.width / camera.detect.height; + } if (isMobile) { if (isPortrait) { return "absolute left-2 right-2 top-[50%] -translate-y-[50%]"; } else { - if (aspect > 16 / 9) { + if (aspect > 1.5) { return "p-2 absolute left-0 top-[50%] -translate-y-[50%]"; } else { return "p-2 absolute top-2 bottom-2 left-[50%] -translate-x-[50%]"; @@ -165,7 +176,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { } if (fullscreen) { - if (aspect > 16 / 9) { + if (aspect > 1.5) { return "absolute inset-x-2 top-[50%] -translate-y-[50%]"; } else { return "absolute inset-y-2 left-[50%] -translate-x-[50%]"; @@ -173,7 +184,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { } else { return "absolute top-2 bottom-2 left-[50%] -translate-x-[50%]"; } - }, [camera, fullscreen, isPortrait]); + }, [camera, fullscreen, isPortrait, fullResolution]); const preferredLiveMode = useMemo(() => { if (isSafari || mic) { @@ -188,8 +199,12 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { }, [windowWidth, windowHeight]); const cameraAspectRatio = useMemo(() => { - return camera.detect.width / camera.detect.height; - }, [camera]); + if (fullResolution.width && fullResolution.height) { + return fullResolution.width / fullResolution.height; + } else { + return camera.detect.width / camera.detect.height; + } + }, [camera, fullResolution]); const aspectRatio = useMemo(() => { if (isMobile || fullscreen) { @@ -347,6 +362,7 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { iOSCompatFullScreen={isIOS} preferredLiveMode={preferredLiveMode} pip={pip} + setFullResolution={setFullResolution} />
{camera.onvif.host != "" && ( @@ -529,7 +545,7 @@ function PtzControlPanel({ - + {ptz?.presets.map((preset) => { return ( void; }; export default function LiveDashboardView({ cameras, + cameraGroup, includeBirdseye, onSelectCamera, }: LiveDashboardViewProps) { @@ -29,11 +40,14 @@ export default function LiveDashboardView({ // layout - const [layout, setLayout] = usePersistence<"grid" | "list">( + const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">( "live-layout", isDesktop ? "grid" : "list", ); + const [isEditMode, setIsEditMode] = useState(false); + const containerRef = useRef(null); + // recent events const { payload: eventUpdate } = useFrigateReviews(); const { data: allEvents, mutate: updateEvents } = useSWR([ @@ -140,35 +154,52 @@ export default function LiveDashboardView({ const birdseyeConfig = useMemo(() => config?.birdseye, [config]); return ( -
+
{isMobile && (
- -
- - +
+
+ {(!cameraGroup || cameraGroup == "default" || isMobileOnly) && ( +
+ + +
+ )} + {cameraGroup && cameraGroup !== "default" && isTablet && ( +
+ +
+ )}
)} @@ -185,41 +216,56 @@ export default function LiveDashboardView({ )} -
- {includeBirdseye && birdseyeConfig?.enabled && ( - onSelectCamera("birdseye")} - /> - )} - {cameras.map((camera) => { - let grow; - const aspectRatio = camera.detect.width / camera.detect.height; - if (aspectRatio > 2) { - grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`; - } else if (aspectRatio < 1) { - grow = `${layout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`; - } else { - grow = "aspect-video"; - } - return ( - onSelectCamera(camera.name)} + {!cameraGroup || cameraGroup == "default" || isMobileOnly ? ( +
+ {includeBirdseye && birdseyeConfig?.enabled && ( + onSelectCamera("birdseye")} /> - ); - })} -
+ )} + {cameras.map((camera) => { + let grow; + const aspectRatio = camera.detect.width / camera.detect.height; + if (aspectRatio > 2) { + grow = `${mobileLayout == "grid" ? "col-span-2" : ""} aspect-wide`; + } else if (aspectRatio < 1) { + grow = `${mobileLayout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`; + } else { + grow = "aspect-video"; + } + return ( + onSelectCamera(camera.name)} + /> + ); + })} +
+ ) : ( + + )}
); } diff --git a/web/src/views/system/GeneralMetrics.tsx b/web/src/views/system/GeneralMetrics.tsx index c62924732..55c0337c0 100644 --- a/web/src/views/system/GeneralMetrics.tsx +++ b/web/src/views/system/GeneralMetrics.tsx @@ -210,6 +210,7 @@ export default function GeneralMetrics({ const series: { [key: string]: { name: string; data: { x: number; y: string }[] }; } = {}; + let hasValidGpu = false; statsHistory.forEach((stats, statsIdx) => { if (!stats) { @@ -221,9 +222,17 @@ export default function GeneralMetrics({ series[key] = { name: key, data: [] }; } - series[key].data.push({ x: statsIdx + 1, y: stats.gpu.slice(0, -1) }); + if (stats.gpu) { + hasValidGpu = true; + series[key].data.push({ x: statsIdx + 1, y: stats.gpu.slice(0, -1) }); + } }); }); + + if (!hasValidGpu) { + return []; + } + return Object.keys(series).length > 0 ? Object.values(series) : []; }, [statsHistory]); @@ -243,6 +252,7 @@ export default function GeneralMetrics({ const series: { [key: string]: { name: string; data: { x: number; y: string }[] }; } = {}; + let hasValidGpu = false; statsHistory.forEach((stats, statsIdx) => { if (!stats) { @@ -254,9 +264,17 @@ export default function GeneralMetrics({ series[key] = { name: key, data: [] }; } - series[key].data.push({ x: statsIdx + 1, y: stats.mem.slice(0, -1) }); + if (stats.mem) { + hasValidGpu = true; + series[key].data.push({ x: statsIdx + 1, y: stats.mem.slice(0, -1) }); + } }); }); + + if (!hasValidGpu) { + return []; + } + return Object.values(series); }, [statsHistory]); diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 93faa87ca..924a7327e 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -139,7 +139,8 @@ module.exports = { xs: "480px", "2xl": "1440px", "3xl": "1920px", - "4xl": "2560px", + "2k": "2560px", + "4k": "3180px", }, }, }, diff --git a/web/themes/theme-blue.css b/web/themes/theme-blue.css index e07ac2c30..0a18600c7 100644 --- a/web/themes/theme-blue.css +++ b/web/themes/theme-blue.css @@ -1,46 +1,59 @@ @layer base { .theme-blue.light { - --background: 0 0% 100%; + --background: 222.2 50% 98%; + --background-alt: 210 30% 96%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; + --popover: 222 50% 97%; --popover-foreground: 222.2 84% 4.9%; - --primary: 221.2 83.2% 53.3%; + --primary: 221.2 33.2% 53.3%; + --primary-variant: 221.2 83.2% 43.3%; --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; + --secondary: 210 40% 90.1%; + --secondary-foreground: 222.2 47.4% 61.2%; + --secondary-highlight: 210 40% 94%; --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --muted-foreground: 215.4 46.3% 46.9%; + --accent: 210 40% 80.1%; + --accent-foreground: 222.2 47.4% 57.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; + --warning: 32 100% 50%; + --warning-foreground: 0 0% 0%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 221.2 83.2% 53.3%; - --radius: 0.5rem; + --selected: 217.2 61.2% 59.8%; + --selected-foreground: 221 50% 100%; } .theme-blue.dark { - --background: 222.2 84% 4.9%; + --background: 222.2 84% 8.9%; + --background-alt: 221.2 63.2% 13.3%; --foreground: 210 40% 98%; --card: 217.2 32.6% 17.5%; --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; + --popover: 222.2 54% 18.9%; --popover-foreground: 210 40% 98%; - --primary: 217.2 91.2% 59.8%; + --primary: 217.2 91.2% 79.8%; + --primary-variant: 217.2 91.2% 49.8%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; + --secondary-foreground: 210 60% 78%; + --secondary-highlight: 217.2 32.6% 25.5%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; + --warning: 32 100% 50%; + --warning-foreground: 0 0% 0%; --border: 217.2 36.6% 12.5%; --input: 217.2 38.6% 29.5%; --ring: 224.3 76.3% 48%; + --selected: 217.2 62.6% 59.8%; + --selected-foreground: 222.2 47.4% 11.2%; } } diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index 3d2619c72..804b2f9f2 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -78,8 +78,6 @@ --selected-foreground: hsl(0 0% 100%); --selected-foreground: 0 0% 100%; - --radius: 0.5rem; - --severity_alert: var(--red-800); --severity_alert_dimmed: var(--red-500); diff --git a/web/themes/theme-gold.css b/web/themes/theme-gold.css index 4b9ddaaf4..a4a0b33ba 100644 --- a/web/themes/theme-gold.css +++ b/web/themes/theme-gold.css @@ -1,61 +1,59 @@ -.theme-gold.light { - --background: 38, 77%, 88%; - --foreground: 16, 43%, 34%; +@layer base { + .theme-gold.light { + --background: 38 77% 88%; + --background-alt: 39 100% 85%; + --foreground: 16 43% 34%; + --muted: 28 46% 56%; + --muted-foreground: 16 43% 34%; + --popover: 38 77% 88%; + --popover-foreground: 16 43% 34%; + --border: 28 46% 56%; + --input: 28 46% 56%; + --card: 38 77% 88%; + --card-foreground: 16 43% 34%; + --primary: 28 46% 56%; + --primary-variant: 28 46% 46%; + --primary-foreground: 60 70% 90%; + --secondary: 39 100% 73%; + --secondary-foreground: 16 43% 34%; + --secondary-highlight: 39 100% 80%; + --accent: 39 100% 73%; + --accent-foreground: 16 43% 34%; + --destructive: 16 43% 34%; + --destructive-foreground: 38 77% 88%; + --warning: 45 100% 51%; + --warning-foreground: 16 43% 34%; + --ring: 28 46% 56%; + --selected: 39 100% 73%; + --selected-foreground: 16 43% 34%; + } - --muted: 28, 46%, 56%; - --muted-foreground: 16, 43%, 34%; - - --popover: 38, 77%, 88%; - --popover-foreground: 16, 43%, 34%; - - --border: 28, 46%, 56%; - --input: 28, 46%, 56%; - - --card: 38, 77%, 88%; - --card-foreground: 16, 43%, 34%; - - --primary: 28, 46%, 56%; - --primary-foreground: 60, 70%, 90%; - - --secondary: 39, 100%, 73%; - --secondary-foreground: 16, 43%, 34%; - - --accent: 39, 100%, 73%; - --accent-foreground: 16, 43%, 34%; - - --destructive: 16, 43%, 34%; - --destructive-foreground: 38, 77%, 88%; - - --ring: 28, 46%, 56%; -} - -.theme-gold.dark { - --background: 16, 43%, 34%; - --foreground: 38, 77%, 88%; - - --muted: 28, 46%, 56%; - --muted-foreground: 38, 77%, 88%; - - --popover: 16, 43%, 34%; - --popover-foreground: 38, 77%, 88%; - - --border: 28, 46%, 56%; - --input: 28, 46%, 56%; - - --card: 16, 43%, 34%; - --card-foreground: 38, 77%, 88%; - - --primary: 28, 46%, 56%; - --primary-foreground: 60, 70%, 90%; - - --secondary: 39, 100%, 73%; - --secondary-foreground: 16, 43%, 34%; - - --accent: 39, 100%, 52%; - --accent-foreground: 16, 43%, 34%; - - --destructive: 38, 77%, 88%; - --destructive-foreground: 16, 43%, 34%; - - --ring: 28, 46%, 56%; + .theme-gold.dark { + --background: 28 46% 28%; + --background-alt: 28 46% 34%; + --foreground: 38 77% 88%; + --muted: 28 46% 56%; + --muted-foreground: 38 77% 88%; + --popover: 25 45% 34%; + --popover-foreground: 38 77% 88%; + --border: 28 46% 56%; + --input: 28 46% 56%; + --card: 16 43% 34%; + --card-foreground: 38 77% 88%; + --primary: 28 46% 50%; + --primary-variant: 28 46% 46%; + --primary-foreground: 60 70% 90%; + --secondary: 39 60% 63%; + --secondary-foreground: 28 50% 44%; + --secondary-highlight: 39 100% 65%; + --accent: 39 100% 52%; + --accent-foreground: 16 43% 34%; + --destructive: 38 77% 88%; + --destructive-foreground: 16 43% 34%; + --warning: 45 100% 51%; + --warning-foreground: 16 43% 34%; + --ring: 28 46% 56%; + --selected: 39 100% 52%; + --selected-foreground: 16 43% 34%; + } } diff --git a/web/themes/theme-green.css b/web/themes/theme-green.css index 9919c00ca..f2816d953 100644 --- a/web/themes/theme-green.css +++ b/web/themes/theme-green.css @@ -1,46 +1,59 @@ @layer base { .theme-green.light { - --background: 0 0% 100%; + --background: 145 70% 98%; + --background-alt: 145 70% 96%; --foreground: 240 10% 3.9%; --card: 0 0% 100%; --card-foreground: 240 10% 3.9%; - --popover: 0 0% 100%; + --popover: 145 20% 95%; --popover-foreground: 240 10% 3.9%; --primary: 142.1 76.2% 36.3%; + --primary-variant: 142.1 76.2% 26.3%; --primary-foreground: 355.7 100% 97.3%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - --muted: 240 4.8% 95.9%; + --secondary: 145 34.8% 85.9%; + --secondary-foreground: 145 35.9% 50%; + --secondary-highlight: 145 70% 92%; + --muted: 145 14.8% 75.9%; --muted-foreground: 240 3.8% 46.1%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; + --accent: 145 34.8% 85.9%; + --accent-foreground: 145 15.9% 50%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; + --warning: 45 100% 51%; + --warning-foreground: 240 10% 3.9%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 142.1 76.2% 36.3%; - --radius: 0.5rem; + --selected: 142.1 76.2% 36.3%; + --selected-foreground: 0 0% 100%; } .theme-green.dark { - --background: 20 14.3% 4.1%; + --background: 145 34.3% 9.1%; + --background-alt: 145 30% 11%; --foreground: 0 0% 95%; --card: 24 9.8% 10%; --card-foreground: 0 0% 95%; - --popover: 0 0% 9%; + --popover: 145 24% 9%; --popover-foreground: 0 0% 95%; - --primary: 142.1 70.6% 45.3%; + --primary: 142.1 70.6% 38.3%; + --primary-variant: 142.1 70.6% 35.3%; --primary-foreground: 144.9 80.4% 10%; - --secondary: 240 3.7% 15.9%; + --secondary: 145 13.7% 15.9%; --secondary-foreground: 0 0% 98%; - --muted: 0 0% 15%; - --muted-foreground: 240 5% 64.9%; - --accent: 12 6.5% 15.1%; + --secondary-highlight: 145 30% 20%; + --muted: 145 20% 15%; + --muted-foreground: 145 25% 64.9%; + --accent: 145 16.5% 25.1%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 85.7% 97.3%; + --warning: 45 100% 51%; + --warning-foreground: 0 0% 95%; --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; --ring: 142.4 71.8% 29.2%; + --selected: 142.1 70.6% 45.3%; + --selected-foreground: 144.9 80.4% 10%; } } diff --git a/web/themes/theme-nature.css b/web/themes/theme-nature.css index 6fcd1be03..f7e29c512 100644 --- a/web/themes/theme-nature.css +++ b/web/themes/theme-nature.css @@ -1,61 +1,59 @@ -.theme-nature.light { - --background: 58, 65%, 83%; - --foreground: 187, 33%, 43%; +@layer base { + .theme-nature.light { + --background: 70 30% 92%; + --background-alt: 58 45% 89%; + --foreground: 187 33% 43%; + --muted: 70 20% 68%; + --muted-foreground: 187 33% 43%; + --popover: 58 45% 83%; + --popover-foreground: 187 33% 43%; + --border: 93 28% 56%; + --input: 93 28% 56%; + --card: 58 65% 83%; + --card-foreground: 187 33% 43%; + --primary: 93 28% 56%; + --primary-variant: 93 28% 46%; + --primary-foreground: 58 65% 83%; + --secondary: 70 20% 78%; + --secondary-foreground: 187 33% 43%; + --secondary-highlight: 70 30% 75%; + --accent: 70 20% 68%; + --accent-foreground: 187 33% 43%; + --destructive: 187 33% 43%; + --destructive-foreground: 58 65% 83%; + --warning: 45 100% 51%; + --warning-foreground: 187 33% 43%; + --ring: 93 28% 56%; + --selected: 70 20% 68%; + --selected-foreground: 187 33% 43%; + } - --muted: 70, 20%, 68%; - --muted-foreground: 187, 33%, 43%; - - --popover: 58, 65%, 83%; - --popover-foreground: 187, 33%, 43%; - - --border: 93, 28%, 56%; - --input: 93, 28%, 56%; - - --card: 58, 65%, 83%; - --card-foreground: 187, 33%, 43%; - - --primary: 93, 28%, 56%; - --primary-foreground: 58, 65%, 83%; - - --secondary: 70, 20%, 68%; - --secondary-foreground: 187, 33%, 43%; - - --accent: 70, 20%, 68%; - --accent-foreground: 187, 33%, 43%; - - --destructive: 187, 33%, 43%; - --destructive-foreground: 58, 65%, 83%; - - --ring: 93, 28%, 56%; -} - -.theme-nature.dark { - --background: 187, 33%, 43%; - --foreground: 58, 65%, 83%; - - --muted: 70, 20%, 68%; - --muted-foreground: 58, 65%, 83%; - - --popover: 187, 33%, 43%; - --popover-foreground: 58, 65%, 83%; - - --border: 93, 28%, 56%; - --input: 93, 28%, 56%; - - --card: 187, 33%, 43%; - --card-foreground: 58, 65%, 83%; - - --primary: 93, 28%, 56%; - --primary-foreground: 187, 33%, 43%; - - --secondary: 70, 20%, 68%; - --secondary-foreground: 58, 65%, 83%; - - --accent: 70, 20%, 68%; - --accent-foreground: 58, 65%, 83%; - - --destructive: 58, 65%, 83%; - --destructive-foreground: 187, 33%, 43%; - - --ring: 93, 28%, 56%; + .theme-nature.dark { + --background: 187 33% 43%; + --background-alt: 187 33% 33%; + --foreground: 58 65% 83%; + --muted: 70 20% 68%; + --muted-foreground: 58 65% 83%; + --popover: 187 33% 43%; + --popover-foreground: 58 65% 83%; + --border: 93 28% 56%; + --input: 93 28% 56%; + --card: 187 33% 43%; + --card-foreground: 58 65% 83%; + --primary: 93 38% 36%; + --primary-variant: 93 28% 46%; + --primary-foreground: 187 33% 43%; + --secondary: 70 20% 58%; + --secondary-foreground: 58 65% 83%; + --secondary-highlight: 70 30% 60%; + --accent: 70 20% 68%; + --accent-foreground: 58 65% 83%; + --destructive: 58 65% 83%; + --destructive-foreground: 187 33% 43%; + --warning: 45 100% 51%; + --warning-foreground: 187 33% 43%; + --ring: 93 28% 56%; + --selected: 70 20% 68%; + --selected-foreground: 58 65% 83%; + } } diff --git a/web/themes/theme-netflix.css b/web/themes/theme-netflix.css index 93f8770a0..641884b45 100644 --- a/web/themes/theme-netflix.css +++ b/web/themes/theme-netflix.css @@ -1,61 +1,59 @@ -.theme-netflix.light { - --background: 0, 0%, 100%; - --foreground: 0, 0%, 0%; +@layer base { + .theme-netflix.light { + --background: 0 0% 100%; + --background-alt: 0 0% 96%; + --foreground: 0 0% 0%; + --muted: 0 0% 68%; + --muted-foreground: 0 0% 0%; + --popover: 0 0% 95%; + --popover-foreground: 0 0% 0%; + --border: 0 68% 39%; + --input: 0 68% 39%; + --card: 0 0% 95%; + --card-foreground: 0 0% 0%; + --primary: 0 100% 44%; + --primary-variant: 0 100% 34%; + --primary-foreground: 0 0% 100%; + --secondary: 0 0% 80%; + --secondary-foreground: 0 0% 100%; + --secondary-highlight: 0 0% 10%; + --accent: 0 100% 50%; + --accent-foreground: 0 0% 0%; + --destructive: 0 68% 39%; + --destructive-foreground: 0 0% 100%; + --warning: 45 100% 51%; + --warning-foreground: 0 0% 0%; + --ring: 0 100% 44%; + --selected: 0 100% 44%; + --selected-foreground: 0 0% 100%; + } - --muted: 0, 0%, 68%; - --muted-foreground: 0, 0%, 0%; - - --popover: 0, 0%, 95%; - --popover-foreground: 0, 0%, 0%; - - --border: 0, 68%, 39%; - --input: 0, 68%, 39%; - - --card: 0, 0%, 95%; - --card-foreground: 0, 0%, 0%; - - --primary: 0, 100%, 44%; - --primary-foreground: 0, 0%, 100%; - - --secondary: 0, 0%, 0%; - --secondary-foreground: 0, 0%, 100%; - - --accent: 0, 100%, 50%; - --accent-foreground: 0, 0%, 0%; - - --destructive: 0, 68%, 39%; - --destructive-foreground: 0, 0%, 100%; - - --ring: 0, 100%, 44%; -} - -.theme-netflix.dark { - --background: 0, 0%, 0%; - --foreground: 0, 0%, 100%; - - --muted: 0, 0%, 50%; - --muted-foreground: 0, 0%, 100%; - - --popover: 0, 0%, 5%; - --popover-foreground: 0, 0%, 100%; - - --border: 0, 68%, 39%; - --input: 0, 68%, 39%; - - --card: 0, 0%, 5%; - --card-foreground: 0, 0%, 100%; - - --primary: 0, 100%, 44%; - --primary-foreground: 0, 0%, 0%; - - --secondary: 0, 0%, 100%; - --secondary-foreground: 0, 0%, 0%; - - --accent: 0, 100%, 50%; - --accent-foreground: 0, 0%, 100%; - - --destructive: 0, 68%, 39%; - --destructive-foreground: 0, 0%, 100%; - - --ring: 0, 100%, 44%; + .theme-netflix.dark { + --background: 0 0% 0%; + --background-alt: 0 0% 5%; + --foreground: 0 0% 100%; + --muted: 0 0% 50%; + --muted-foreground: 0 0% 100%; + --popover: 0 0% 5%; + --popover-foreground: 0 0% 100%; + --border: 0 68% 39%; + --input: 0 68% 39%; + --card: 0 0% 5%; + --card-foreground: 0 0% 100%; + --primary: 0 70% 44%; + --primary-variant: 0 100% 34%; + --primary-foreground: 0 0% 0%; + --secondary: 0 0% 20%; + --secondary-foreground: 0 0% 40%; + --secondary-highlight: 0 0% 90%; + --accent: 0 100% 50%; + --accent-foreground: 0 0% 100%; + --destructive: 0 68% 39%; + --destructive-foreground: 0 0% 100%; + --warning: 45 100% 51%; + --warning-foreground: 0 0% 0%; + --ring: 0 100% 44%; + --selected: 0 100% 44%; + --selected-foreground: 0 0% 0%; + } } diff --git a/web/themes/theme-nord.css b/web/themes/theme-nord.css index 5a5f95daf..ab8ead0e4 100644 --- a/web/themes/theme-nord.css +++ b/web/themes/theme-nord.css @@ -1,61 +1,59 @@ -.theme-nord.light { - --background: 220, 16%, 96%; - --foreground: 222, 20%, 16%; +@layer base { + .theme-nord.light { + --background: 220 16% 96%; + --background-alt: 215 14% 92%; + --foreground: 222 20% 16%; + --muted: 215 14% 80%; + --muted-foreground: 222 20% 16%; + --popover: 220 16% 96%; + --popover-foreground: 222 20% 16%; + --border: 222 20% 70%; + --input: 222 20% 70%; + --card: 220 16% 96%; + --card-foreground: 222 20% 16%; + --primary: 222 20% 35%; + --primary-variant: 222 20% 60%; + --primary-foreground: 220 16% 96%; + --secondary: 215 14% 80%; + --secondary-foreground: 222 20% 16%; + --secondary-highlight: 215 14% 88%; + --accent: 215 14% 80%; + --accent-foreground: 222 20% 16%; + --destructive: 0 80% 50%; + --destructive-foreground: 222 20% 16%; + --warning: 45 100% 51%; + --warning-foreground: 222 20% 16%; + --ring: 222 20% 70%; + --selected: 222 20% 70%; + --selected-foreground: 220 16% 96%; + } - --muted: 215, 14%, 80%; - --muted-foreground: 222, 20%, 16%; - - --popover: 220, 16%, 96%; - --popover-foreground: 222, 20%, 16%; - - --border: 222, 20%, 70%; - --input: 222, 20%, 70%; - - --card: 220, 16%, 96%; - --card-foreground: 222, 20%, 16%; - - --primary: 222, 20%, 70%; - --primary-foreground: 220, 16%, 96%; - - --secondary: 215, 14%, 80%; - --secondary-foreground: 222, 20%, 16%; - - --accent: 215, 14%, 80%; - --accent-foreground: 222, 20%, 16%; - - --destructive: 0, 80%, 50%; - --destructive-foreground: 222, 20%, 16%; - - --ring: 222, 20%, 70%; -} - -.theme-nord.dark { - --background: 220, 16%, 16%; - --foreground: 222, 20%, 96%; - - --muted: 215, 14%, 25%; - --muted-foreground: 222, 20%, 96%; - - --popover: 220, 16%, 16%; - --popover-foreground: 222, 20%, 96%; - - --border: 222, 20%, 40%; - --input: 222, 20%, 40%; - - --card: 220, 16%, 16%; - --card-foreground: 222, 20%, 96%; - - --primary: 222, 20%, 65%; - --primary-foreground: 220, 16%, 16%; - - --secondary: 215, 14%, 25%; - --secondary-foreground: 222, 20%, 96%; - - --accent: 215, 14%, 25%; - --accent-foreground: 222, 20%, 96%; - - --destructive: 0, 80%, 50%; - --destructive-foreground: 222, 20%, 96%; - - --ring: 222, 20%, 40%; + .theme-nord.dark { + --background: 220 16% 16%; + --background-alt: 215 14% 12%; + --foreground: 222 20% 96%; + --muted: 215 14% 25%; + --muted-foreground: 222 20% 66%; + --popover: 220 16% 16%; + --popover-foreground: 222 20% 66%; + --border: 222 20% 40%; + --input: 222 20% 40%; + --card: 220 16% 16%; + --card-foreground: 222 20% 96%; + --primary: 222 20% 75%; + --primary-variant: 222 20% 55%; + --primary-foreground: 220 16% 16%; + --secondary: 215 14% 25%; + --secondary-foreground: 222 20% 86%; + --secondary-highlight: 215 14% 35%; + --accent: 215 14% 25%; + --accent-foreground: 222 20% 96%; + --destructive: 0 80% 50%; + --destructive-foreground: 222 20% 96%; + --warning: 45 100% 51%; + --warning-foreground: 222 20% 96%; + --ring: 222 20% 40%; + --selected: 222 20% 55%; + --selected-foreground: 220 16% 16%; + } } diff --git a/web/themes/theme-orange.css b/web/themes/theme-orange.css index 57a26182c..5e428c33a 100644 --- a/web/themes/theme-orange.css +++ b/web/themes/theme-orange.css @@ -1,46 +1,59 @@ @layer base { .theme-orange.light { - --background: 0 0% 100%; + --background: 24 50% 98.3%; + --background-alt: 30 50% 96%; --foreground: 20 14.3% 4.1%; --card: 0 0% 100%; --card-foreground: 20 14.3% 4.1%; - --popover: 0 0% 100%; - --popover-foreground: 20 14.3% 4.1%; - --primary: 24.6 95% 53.1%; + --popover: 24 30% 95%; + --popover-foreground: 24 24.3% 14.1%; + --primary: 24.6 55% 53.1%; + --primary-variant: 24.6 55% 43.1%; --primary-foreground: 60 9.1% 97.8%; - --secondary: 60 4.8% 95.9%; - --secondary-foreground: 24 9.8% 10%; - --muted: 60 4.8% 95.9%; - --muted-foreground: 25 5.3% 44.7%; - --accent: 60 4.8% 95.9%; - --accent-foreground: 24 9.8% 10%; + --secondary: 24 54.8% 95.9%; + --secondary-foreground: 24 79.8% 60%; + --secondary-highlight: 30 50% 92%; + --muted: 24 4.8% 75.9%; + --muted-foreground: 25 25.3% 44.7%; + --accent: 24 14.8% 95.9%; + --accent-foreground: 24 14.8% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 60 9.1% 97.8%; + --warning: 45 100% 51%; + --warning-foreground: 20 14.3% 4.1%; --border: 20 5.9% 90%; - --input: 20 5.9% 90%; + --input: 24 25.9% 90%; --ring: 24.6 95% 53.1%; - --radius: 0.5rem; + --selected: 24.6 85% 53.1%; + --selected-foreground: 60 9.1% 97.8%; } .theme-orange.dark { --background: 20 14.3% 4.1%; + --background-alt: 20 14.3% 9.1%; --foreground: 60 9.1% 97.8%; --card: 20 14.3% 4.1%; --card-foreground: 60 9.1% 97.8%; --popover: 20 14.3% 4.1%; --popover-foreground: 60 9.1% 97.8%; - --primary: 20.5 90.2% 48.2%; + --primary: 20.5 90.2% 68.2%; + --primary-variant: 20.5 90.2% 58.2%; --primary-foreground: 60 9.1% 97.8%; --secondary: 12 6.5% 15.1%; - --secondary-foreground: 60 9.1% 97.8%; - --muted: 12 6.5% 15.1%; - --muted-foreground: 24 5.4% 63.9%; - --accent: 12 6.5% 15.1%; - --accent-foreground: 60 9.1% 97.8%; + --secondary-foreground: 20 49.1% 57.8%; + --secondary-highlight: 12 6.5% 25.1%; + --muted: 20.5 26.5% 15.1%; + --muted-foreground: 20 25.4% 53.9%; + --accent: 20 16.5% 15.1%; + --accent-foreground: 20 19.1% 79.8%; --destructive: 0 72.2% 50.6%; --destructive-foreground: 60 9.1% 97.8%; + --warning: 45 100% 51%; + --warning-foreground: 60 9.1% 97.8%; --border: 12 6.5% 15.1%; --input: 12 6.5% 15.1%; --ring: 20.5 90.2% 48.2%; + --selected: 20.5 90.2% 38.2%; + --selected-foreground: 60 9.1% 97.8%; } } diff --git a/web/themes/theme-red.css b/web/themes/theme-red.css index d52fd4e07..f522d74b7 100644 --- a/web/themes/theme-red.css +++ b/web/themes/theme-red.css @@ -1,46 +1,59 @@ @layer base { .theme-red.light { --background: 0 0% 100%; + --background-alt: 0 0% 98%; --foreground: 240 10% 3.9%; --card: 0 0% 100%; --card-foreground: 240 10% 3.9%; --popover: 0 0% 100%; --popover-foreground: 240 10% 3.9%; --primary: 346.8 77.2% 49.8%; + --primary-variant: 346.8 77.2% 39.8%; --primary-foreground: 355.7 100% 97.3%; --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; + --secondary-foreground: 0 35.9% 50%; + --secondary-highlight: 0 0% 92%; --muted: 240 4.8% 95.9%; --muted-foreground: 240 3.8% 46.1%; --accent: 240 4.8% 95.9%; --accent-foreground: 240 5.9% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; + --warning: 45 100% 51%; + --warning-foreground: 240 10% 3.9%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 346.8 77.2% 49.8%; - --radius: 0.5rem; + --selected: 346.8 77.2% 49.8%; + --selected-foreground: 355.7 100% 97.3%; } .theme-red.dark { --background: 20 14.3% 4.1%; + --background-alt: 0 0% 9%; --foreground: 0 0% 95%; --card: 24 9.8% 10%; --card-foreground: 0 0% 95%; --popover: 0 0% 9%; --popover-foreground: 0 0% 95%; - --primary: 346.8 77.2% 49.8%; + --primary: 0 37.2% 90.8%; + --primary-variant: 346.8 77.2% 39.8%; --primary-foreground: 355.7 100% 97.3%; --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; + --secondary-highlight: 0 0% 25.9%; --muted: 0 0% 15%; --muted-foreground: 240 5% 64.9%; --accent: 12 6.5% 15.1%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 85.7% 97.3%; + --warning: 45 100% 51%; + --warning-foreground: 0 0% 95%; --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; --ring: 346.8 77.2% 49.8%; + --selected: 346.8 77.2% 39.8%; + --selected-foreground: 355.7 100% 97.3%; } }